DeepAgents(十九):自定义 Profile 接本地模型
自定义 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 直接没了。
顺带说一件事。官方自己那 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%。
代价是那 2 个工具真的没了。模型不会再自己去搜文件内容,你得在提示词里把路径说清楚,或者让它先 ls 看一眼。省下来的 1380 个 token,是拿这 2 项能力换的。
所以这份档值不值得写,取决于你更缺什么。窗口小、跑得慢、按 token 付费,就值得;工具面本来就要全,写完也只是把数字搬了个位置。
自己写一份 profile 不难,2 个函数、2 种 key 形状,一行调用就生效。难的是想清楚这次要动哪一类字段。接本地模型这件事,注册只是开头,模型的厂名、磁盘的后端,还有那些看不见的推理 token,才是真正要花时间的地方。