DeepAgents(八):自己写一个规划中间件,四个坑
自己写一个规划中间件,四个坑
官方的 todo 中间件装回默认栈,一轮固定多 1463 token。拆开看,1121 是工具描述,342 是提示词注入,钱基本都花在写给模型看的说明书上。
那我照着写一个,把说明书换成自己的话,这笔钱是不是就省下来了。
我照着写了,省下 1174。但省钱是明面上的账,真正决定它能不能用的事都不在钱上。
自己写一个规划中间件,省下的 token 是明面上的账。真正决定它好不好用的是四件事:清单存在哪个通道、写一次是覆盖还是合并、工具回执漏了会怎样、清单要不要每轮重新交给模型看。
4 个坑都在下面。全程不用 API key,读完约 6 分钟。
一、先看要照着写的那个原件
官方那个中间件全身就 3 样东西。
| 部位 | 长什么样 |
|---|---|
| 一个状态字段 | todos,列表,每项是 content 加 status |
| 一个工具 | write_todos,描述 3873 字符 |
| 一段提示词 | 1370 字符,拼在 system 末尾 |
它还挂了 2 个钩子:一个负责往 system 里追加提示词,另一个负责拦住并行调用。
这里有件事值得先说清楚:那段提示词和那份工具描述,都能在构造的时候换掉。
TodoListMiddleware(
system_prompt=你的提示词,
tool_description=你的描述,
)
如果你只是嫌它贵,换 2 个参数就够了,省下的钱跟从头写一个一样多。
剩下 4 件事,才是自己写要付的代价。
二、坑一:通道名写错,一声不响
工具写清单,靠返回一个 Command,把新值按 key 更新进状态:
return Command(update={"todos": todos})
这里的 todos 必须跟状态里那个字段同名。写成 todo,少一个 s,会怎么样?
我试了。不报错,不警告,工具照常返回成功,回执照常写进消息,只有状态里那个通道是空的。
| 写法 | 结果 |
|---|---|
update={"todos": ...} |
3 项进状态 |
update={"todo": ...} |
通道是空的,不报错,回执照写 |
自己写的时候这个错特别容易犯。字段名换成 plan 或者 tasks 之后,工具里写一个、状态里写另一个,跑起来一切正常,清单就是不动。
改完名字先跑一遍,看一眼状态里那个键有没有值。这一眼能省掉半天。
三、坑二:合并规则决定「写一次」是什么意思
官方那个 todos 字段上只挂了 1 个标注,没有配合并规则。
没配合并规则,写进去的值就是整段覆盖。我让它先建 3 项,再调一次只写 1 项,状态里只剩最后那 1 项。
自己写的时候可以给它配一条合并规则,同名的覆盖、新的追加:
def merge_todos(left, right):
"""按 content 合并,同名的覆盖。"""
out = list(left or [])
seen = {}
for i, t in enumerate(out):
seen[t["content"]] = i
for t in right or []:
key = t["content"]
if key in seen:
out[seen[key]] = t
else:
seen[key] = len(out)
out.append(t)
return out
配好之后同样 2 步操作,结果是 3 项,其中「改实现」这一项变成了 completed。模型每次只报变化的那几项,剩下的不用重发。
这一条顺带解释了官方为什么必须拦并行
官方那里有个钩子在数:同一轮里出现 2 个 write_todos,就把 2 条都判错。
我原来以为这是防模型乱来。配上合并规则才明白,这是覆盖语义下的必然结果:两边都声称自己写的是完整清单,谁赢没定义。
拦下来的代价是当场作废。2 条都报错,状态里那个字段保持 None,模型得再来一轮。配上合并规则之后,同一轮发 2 条不报错,3 项和 1 项合成 4 项。
四、坑三:漏了回执,直接报错
工具的返回值里除了清单,还得带一条工具回执。我一开始只写了清单:
return Command(update={"todos": todos})
跑起来直接停住,报错原文是:
Expected to have a matching ToolMessage in Command.update for tool ... Every tool call in the message history MUST have a corresponding ToolMessage.
意思是每条工具调用都必须有一条对应的回执消息。只更新状态,回执就悬空了。
官方那版是连回执一起返回的:
def update_plan(todos, tool_call_id):
note = ToolMessage(
"ok", tool_call_id=tool_call_id
)
return Command(update={
"todos": todos,
"messages": [note],
})
tool_call_id 从函数参数注入,不用自己拼。
五、坑四:官方从不把清单交给模型看
这一条是写完才发现的,也是 4 个里最值钱的。
官方那版每次调用模型前,都会把 system prompt 加长 1372 字符。我一直以为里面带着当前清单。抓下来看,全是固定的说明文字,清单一项都没有。
| 版本 | 第 1 轮 system | 第 2 轮 system | 含清单项 |
|---|---|---|---|
| 官方 | 1372 字符 | 1372 字符 | 0 / 3 |
| 自己写·不注入 | 100 字符 | 100 字符 | 0 / 3 |
| 自己写·每轮拼进去 | 100 字符 | 165 字符 | 3 / 3 |
那清单在哪?只在 2 个地方:状态里那个通道,以及工具回执那条消息。模型下一轮能看见清单,靠的是上一轮那条回执还留在消息历史里。
回执被摘要吃掉之后
消息历史是会缩的。撑到 44 条,摘要中间件就动手了。
它的返回很有意思:更新里只有 messages 一个键,不碰状态里那个清单字段;而新的消息里,那条清单回执剩下 0 条。
| 项 | 摘要前 | 摘要后 |
|---|---|---|
| 状态里的清单 | 3 项 | 3 项 |
| 消息条数 | 44 | 6 |
| 消息里的清单回执 | 1 条 | 0 条 |
| 模型能看到的清单 | 靠那条回执 | 看不见了 |
清单自己一直是 3 项,丢的是模型看它的那条路。
自己写的时候,把清单在每次调用前拼进 system 就能补上这一步。钩子签名是 wrap_model_call(self, req, handler),关键就这几行:
state = req.state or {}
todos = state.get("todos") or []
lines = ""
for t in todos:
s = t["status"]
c = t["content"]
lines += f"- [{s}] {c}\n"
block = self.system_prompt
if lines:
head = "\n当前清单:\n"
block += head + lines
再把这个 block 拼进 system,交给下一环就行。
这样第 2 轮的 system 是 165 字符,3 项全在里面。代价是每轮多几十字符,跟整条回执被摘要吃掉的损耗不在一个量级。
官方没做这一步,我猜是把清单当成模型自己写过的草稿,默认它记得。长任务里这个默认不成立。
六、一笔账:省 1174,付 289
两边放进同一张表,口径跟前面一样,工具 token 用 count_tokens_approximately 量。
| 项 | 官方 | 自己写 | 差 |
|---|---|---|---|
| 工具(名字加描述加参数) | 1121 | 265 | −856 |
| 提示词注入 | 342 | 24 | −318 |
| 一轮固定开销 | 1463 | 289 | −1174 |
省下的 1174 是官方那份开销的 8 成。原因很朴素:官方那份工具描述 3873 字符,我的 131 字符。它要把「什么时候该用、什么时候不该用、3 种状态怎么标、收尾时答案必须另起一条消息」全写进去,我只写了 3 句话。
代价在别处。官方那 1463 token 买的是「不用自己维护」。自己写之后,这 4 件事都归你:状态字段、合并规则、工具回执、注入时机。
还有一件官方替我做了的事是拦并行。配上合并规则之后我不需要它了,但如果不配,那就得自己写个钩子去数。
七、所以什么时候该自己写
只是嫌贵,别写。 换 2 个构造参数就行,省下的钱一样多,还不用维护。
要按项增量更新,写。 官方那份是全量覆盖,模型每轮得把整份清单重发一遍。配上合并规则,模型只报变化的那几项。
清单要活到很多轮之后,写。 长任务里消息历史一定会缩,回执迟早被压掉。自己写能每轮把清单拼进 system,让模型始终看得见。
要在写清单时做校验,写。 官方只在描述里写「每项都要标状态」这类要求,做不到就做不到。自己写的钩子可以真拦,比如把已完成的项改回待办就直接拒掉。
这 4 条都不沾,就用官方那份。1463 token 换一整套不用维护的行为,在长任务里不算贵。
最后补一句。我原本以为「自己写一个」的价值在省钱,写完才发现省钱是最容易的那部分。
4 个坑里有 2 个是完全静默的:通道名写错不报错,清单没交给模型看也不报错。剩下 2 个里,漏回执会当场停住,合并规则配不配只是行为不同。
那 1174 是我在本机用 count_tokens_approximately 量的近似值,不是账单。换模型分词器不一样,会有出入。量级可信,具体数字按你的模型再算一遍。