DeepAgents(十九):自定义 Profile 接本地模型

2026-10-25 20:550 条评论1 次阅读

自定义 Profile 接本地模型

包里内置了 14 个 profile 档,全是国外模型,国产和本地一个都没覆盖。这不影响东西能跑,因为没命中就等于什么都不做。真要动手是自己写一份,而把本地模型接进来这条路,注册只是开头,后面还有 3 道关。

一、内置 14 个 harness 档,3 个家族,没有国产模型

deepagents 的 profiles 包一共 15 个 .py、188778 字节,按阶段切成 2 套注册表。

provider 那套管模型怎么被造出来,构造参数、请求头、构造前的检查都在这一层。harness 那套管 Agent 怎么跑,系统提示、工具面、中间件栈都在这一层。两套表都是懒加载,读之前是 0 个,第 1 次查表才去填,填完变成 14 和 3。

14 个 harness key 分 3 个家族:Anthropic 3 个、OpenAI Codex 3 个、NVIDIA Nemotron 8 个。

那 8 个 Nemotron key 值得单看一眼。它们落在 7 个前缀上,NVIDIA、nvidia、baseten、fireworks、nebius、openrouter、together。NVIDIA 和 nvidia 是同一个源,去重之后是 6 家托管方。

算下来,14 个 key 背后只有 5 个 profile 对象。同一个对象被多个 key 共用,Nemotron 那 8 个共用一个,Codex 那 3 个共用一个。

我把整个包的 53 个 .py 翻了一遍,搜 12 个国产模型关键词,命中 0 处。再搜 7 个本地推理关键词,还是 0 处。作为对照,8 个国外厂商关键词命中 270 处。

那没命中会怎样。框架返回 1 个空对象,不会报错。

我拿 3 个国产或本地的模型名真跑了一遍装配,和不传模型规格的基线逐项对上:system 0 字符、工具 8 个、中间件 5 个,三项完全一致。这 8 个工具是 ls、read_file、write_file、edit_file、delete、glob、grep、task。

所以内置档里没有你的模型,意味着少一层别人替你调好的参数,不意味着少一条腿。

二、注册用 2 个函数,harness 档有 7 个字段

注册入口只有 2 个函数,一个管 Agent 怎么跑,一个管模型怎么造:

import deepagents as da

KEY = "openai:qwen/qwen3.5-9b"
prof = da.HarnessProfile(
    system_prompt_suffix="Be brief.",
    excluded_tools=frozenset({"ls"}),
)
da.register_harness_profile(KEY, prof)

key 只有 2 种形状,provider 或者 provider:model。前者管整个厂,后者管一个型号,2 个都注册的话型号级会把厂级合进来。

key 写错了当场报错,不会静默失效。我试了 9 种错法,空串、纯空白、前后带空格、2 个冒号、冒号左边空、右边空、冒号旁边留空格,全部 ValueError。

harness 这边的档有 7 个字段,默认值全是空的:

字段 默认 它改什么
base_system_prompt None 系统提示的主体
system_prompt_suffix None 接在系统提示末尾的一段
tool_description_overrides 空 逐条换掉工具的描述
excluded_tools 空 把工具从模型眼前撤掉
excluded_middleware 空 卸掉中间件
extra_middleware 空 追加中间件
general_purpose_subagent None 那个默认子 Agent 的开关和提示词

2 个提示词字段同时给的话,拼出来的原文是 [BASE] 加 2 个换行加 [SUFFIX],中间固定空一行。

我把 7 个字段逐个填进去,各装配一次,看它到底动到哪一段:

填的字段 system 工具 工具描述 中间件
基线 0 字符 8 个 5251 字符 5 个
base_system_prompt +5 字符 8 个 5251 5 个
system_prompt_suffix +5 字符 8 个 5251 5 个
tool_description_overrides 0 8 个 5049 字符 5 个
excluded_tools 0 7 个 4694 字符 6 个
excluded_middleware 0 8 个 5251 4 个
extra_middleware 0 8 个 5251 6 个
general_purpose_subagent 0 8 个 5251 5 个

有 2 行要多说一句。

撤掉 1 个工具,中间件反而多 1 个。包里的做法是装一个中间件去拦,不从清单里划掉。

省一份工具描述的开销,同时多一份中间件的开销,算账的时候这 2 笔要一起算。

general_purpose_subagent 只写提示词的话,装配上看不出变化。它的 enabled=False 才会动装配,工具面从 8 个变 7 个,task 直接没了。

7 个字段

提示词:主体 + 末尾

工具面 / 中间件栈

装配:system + 工具 + 中间件

顺带说一件事。官方自己那 5 个内置档,7 个字段里只用了 2 个。

system_prompt_suffix 5 个档都用了,tool_description_overrides 只有 Nemotron 那 1 个档用了。剩下 5 个字段一次都没碰过。

也就是说,官方调这 5 个模型,用的全是加法,没有人靠撤工具或者卸中间件来适配。

这也解释了为什么适配成本都压在提示词上。这 5 个档的提示词后缀长度差得很远,短的 1166 字符,长的 2962 字符,同一个包里差了 2.5 倍。每个模型需要的收口方式不一样,只能一段一段写。

三、同 key 再注册:标量覆盖、集合取并集、映射合并、序列原位替换

第 1 次注册是白纸,怎么写都行。第 2 次开始,4 类字段各有一套叠法。

字段类型 第 2 次注册时 实测
标量(2 个提示词字段) 后者覆盖,不拼接 「第一段。」+「第二段。」⇒ 只剩「第二段。」
集合(2 个 excluded 字段) 取并集 {grep} + {task} ⇒ 2 个都在
映射(工具描述) 逐 key 合并,同 key 后者赢 {ls:甲} + {read_file:乙} ⇒ 2 条都在
序列(extra_middleware) 按类合并,同类新实例原位替换 [甲, 乙] + [甲新] ⇒ [甲新, 乙]

3 个坑都藏在这张表里。

第 1 个坑,集合只能加,不能撤。我注册一个空集合想把前一条撤掉,结果它原封不动。注册这个动作没有对应的撤销接口。

第 2 个坑,两边都带中间件的时候,合并结果是一个惰性工厂。类型变成 function,直接拿去迭代会报 TypeError: 'function' object is not iterable。只有一边带,它才是原样的序列。

第 3 个坑,叠到内置档上,标量覆盖会把内置那段整段顶掉。

内置的 anthropic:claude-sonnet-4-6 自带 1455 字符的行为提示,我叠了一句 7 个字符的进去,装配出来的 system 从 1455 字符变成 7 字符。想在内置那段后面再补一句,得自己把 2 段拼好再注册。

卸中间件这件事也有边界。脚手架类的名字在构造期就被拒,SubAgentMiddleware、FilesystemMiddleware、下划线开头的、带冒号的、空串,5 种写法全是 ValueError。源码给的理由是这几个中间件在背后撑着文件工具、子 Agent 派发和权限校验。

还有一件容易搞混的。同一个名字在 2 张表里各存各的,互不覆盖。

同一个 key 在 harness 表里挂一份提示词、在 provider 表里挂一批构造参数,查的时候各查各的。同名不等于同一份,改一边不会带动另一边。

四、第 1 道关:ChatOpenAI 报的厂名是 openai

profile 怎么写清楚了,接下来是接本地模型。第 1 道关不在 profile 上,在 key 怎么对。

我用 LM Studio 起了本地服务,模型是 qwen/qwen3.5-9b。接法是标准的 OpenAI 兼容:

from langchain_openai import (
    ChatOpenAI,
)

BASE = "http://127.0.0.1:1234/v1"

model = ChatOpenAI(
    model="qwen/qwen3.5-9b",
    base_url=BASE,
    api_key="lm-studio",
)

那我该注册哪个 key。直觉会写 lmstudio:qwen/qwen3.5-9b。这个 key 反查不到任何东西。

因为传进来的是模型对象,框架要从对象上反推厂名和型号名。它取到的厂名是 openai,型号名是 qwen/qwen3.5-9b。

所以真正的 key 是这 2 个字符串拼起来的:

openai:qwen/qwen3.5-9b

ChatOpenAI 这个类不管连的是哪家的端点,取厂名时都报 openai。本地服务只是换了个地址,厂名没变。

型号名那边取的是模型对象上的 model_name 字段,没这个字段就退回 model。2 个都取不到,就只剩厂名,档会退化成厂级。

所以接一个新的本地模型,第 1 步是把它这 2 个值打印出来看一眼。别猜,猜错的成本是你以为档生效了,其实一直是空的。

这件事跑了两遍,结论一致。注册 openai:qwen/qwen3.5-9b,反查命中。注册 lmstudio:qwen3.5-9b,反查拿到空对象。

换个大小写写 openai:QWEN/QWEN3.5-9B 也查不到。harness 这一侧匹配是大小写敏感的,provider 那一侧反而宽松,openai:GPT-5.1-Codex 照样能命中。

还有一件更隐蔽的。key 里只允许 1 个冒号,所以型号名本身带冒号的模型,key 根本注册不进去,直接 ValueError: ... has more than one ':'。这类模型得先把命名改掉,否则档永远挂不上。

openai 前缀在 provider 侧会注入 use_responses_api

对上 key 之后还有一层要留意。openai 这个前缀在 provider 表里也有档,它做的事情是注入 use_responses_api=True。

我查了一下,openai:qwen/qwen3.5-9b 这个 spec 拿到的构造参数就是 {'use_responses_api': True}。而 lmstudio:qwen/qwen3.5-9b 拿到的是空字典,因为 provider 表里没有 lmstudio。

那这个键会不会影响你,要看你走哪条路。

传模型对象的时候,provider 档不参与,use_responses_api 保持默认的 None,请求走的是 Chat Completions。传模型字符串的时候,provider 档会生效,请求改走 Responses API。

2 条路都能跑通工具调用,但回来的东西不一样。传对象时 content 是字符串。传字符串时 content 变成块列表,块类型是 reasoning 和 text 两段。

如果你的下游代码习惯按字符串读 content,走字符串路线会拿到一个列表,得自己拼。想让它回到字符串,注册一个同名 provider 档把 use_responses_api 改成 False 就行,调用方传的 kwargs 优先级最高。

五、第 2 道关:默认 StateBackend 读不到硬盘文件

key 对上之后,我先让模型读一个硬盘上真实存在的文件。结果它连试 3 次都没找到:

模型先调 read_file -> File not found
再调 ls            -> No files found
再调 glob          -> No files found

文件是真在那儿的。问题在默认的文件后端。

包默认装配的是 StateBackend,一个挂在图状态里的虚拟文件盘。Agent 的读写都落在这个虚拟盘上,跟你的硬盘没关系。新建一条会话,盘就是空的。

要读真实文件,换成 FilesystemBackend,把根目录指过去:

from deepagents.backends import (
    FilesystemBackend,
)

fs = FilesystemBackend(root_dir=ROOT)

agent = create_deep_agent(
    model=model,
    system_prompt="Be brief.",
    backend=fs,
)

同样的问题,这次 4 条消息就结束了,第 1 次调 read_file 就读到内容。前 3 次白试的那些 token,在这一版里省下来了。

路径写法要留意。virtual_mode 默认是 True,root_dir 就是根,模型要写 /notes.md 这种以斜杠开头的虚拟路径。带 .. 的会被挡住,报 Path traversal not allowed。

这件事对本地模型的成本格外高。每多试一次工具,就要多带一遍完整上下文,还要多花一轮推理的 token。

六、第 3 道关:推理 token 放大 51 倍

这一关最容易被忽略。

我在本地服务上让它「只回答:收到」,把输出上限设成 32 个 token。回来的是空字符串,32 个 token 全进了推理,finish_reason 是 length。

上限调到 256,正文才出来 2 个字。可它花掉 223 个 token,其中 219 个是推理。

再问一句「用一句话说明你在做什么」,回答 25 个字左右。output 是 1290 个 token,其中推理 1276 个,正文只占 14 个。

放大倍数约 51 倍。

原生返回体里,推理是单独一个字段 reasoning_content,和 content 分开。SDK 会把它落进 usage_metadata 的 output_token_details.reasoning,想算成本就看这一项。

对 Agent 意味着什么。每一轮工具调用之后,模型都要重新想一遍,推理 token 也跟着再花一遍。

15 轮的循环,光推理就是近 2 万个 token。给本地模型设输出上限时,得把推理那部分一起算进去,否则你会看到一堆空回答,还会以为模型不行。

这也是本地小模型和云端大模型一个很实际的差别。推理长度不完全受你控制,同一个问题两次跑,花掉的推理 token 能差几十个。做预算时得留余量,不能按最省的那次算。

七、16GB 显存:9B 模型开 10 万上下文用掉 12.8GB

先说这台机器的实测底数。

项 值
显卡 RTX 4080,16376 MiB
模型 qwen/qwen3.5-9b,Q4_K_M
架构 qwen35
能力 tool_use
最大上下文 262144
当前加载的上下文 102400
已用显存 约 12.8 GB

有 2 个数字要分清。

max_context_length 是模型架构能撑的上限,262144。loaded_context_length 是这次加载实际开出来的窗口,102400,占上限的 39.1%。

开多大窗口是要占显存的。这台机器开 102400,已经用掉约 12.8 GB,只剩 3.5GB 出头。想再往上加窗口,得先换更小的量化,或者换更小的模型。

这 12.8 GB 里也包含桌面和别的进程。9B 的 Q4_K_M 权重本身不到 6GB,剩下的都是上下文那块的占用,窗口越大涨得越快。

capabilities 那一栏也值得看一眼,只有 tool_use 一项。这一项在,工具循环才转得起来。

再说回 profile。8 个工具的完整定义,在本地模型这里是 2758 个 token 的首轮输入。

窗口 102400 看着很宽裕。可你要算进去的是每一轮都带这 2758 个 token,再加上历史消息、工具返回值,还有那些推理。

八、自定义档把首轮输入从 2758 压到 1378

回到最开始那个问题。内置档没覆盖本地模型,自己写一份,到底能改什么。

我用同一个模型、同一个后端,量了 3 档的首轮输入:

档 首轮 input 相对基线
基线(不注册) 2758 token —
只加一句「回答用中文。」 2763 token +5
排掉 grep、glob,把 3 条描述压到几个字 1378 token -1380

写提示词那一步几乎不花钱,一句话 5 个 token。真正能省的是工具面。

因为工具定义是每一轮都要重发的固定开销。8 个工具的完整描述是 5251 个字符。

撤掉 grep 和 glob、把剩下 3 条的描述压短,描述总量掉到 2150 个字符。首轮输入降了 50.0%。

写一份 profile

关 1:key 怎么拼

关 2:后端指向哪

关 3:上限够不够推理花

循环转起来

代价是那 2 个工具真的没了。模型不会再自己去搜文件内容,你得在提示词里把路径说清楚,或者让它先 ls 看一眼。省下来的 1380 个 token,是拿这 2 项能力换的。

所以这份档值不值得写,取决于你更缺什么。窗口小、跑得慢、按 token 付费,就值得;工具面本来就要全,写完也只是把数字搬了个位置。

自己写一份 profile 不难,2 个函数、2 种 key 形状,一行调用就生效。难的是想清楚这次要动哪一类字段。接本地模型这件事,注册只是开头,模型的厂名、磁盘的后端,还有那些看不见的推理 token,才是真正要花时间的地方。