DeepAgents(八):自己写一个规划中间件,四个坑

2026-10-12 22:110 条评论7 次阅读

自己写一个规划中间件,四个坑

官方的 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 件事,才是自己写要付的代价。

坑一:update 的 key 写错,状态通道一动不动

坑二:没有合并规则,写一次就是整段覆盖

坑三:Command 里不带回执,直接报错

坑四:清单不进 system,模型只能靠回执看

二、坑一:通道名写错,一声不响

工具写清单,靠返回一个 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 项,丢的是模型看它的那条路。

清单只存在状态通道和工具回执这 2 个地方

消息撑到 44 条,摘要中间件动手

回执被压掉,system 里又没有,模型看不见了

自己写:每轮拼进 system,165 字符里 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 量的近似值,不是账单。换模型分词器不一样,会有出入。量级可信,具体数字按你的模型再算一遍。