本文适合正在基于 LangGraph 自研多步 Agent 的开发者。很多同学习惯直接把任务计划塞到系统提示词中,但聊天历史会稀释计划、并行工具调用容易打乱进度,长任务下稳定性很差。本文给出一套状态+中间件的方案:将待办作为独立运行时状态,用整表覆盖语义更新计划,通过钩子做一致性校验,把规划逻辑和业务领域工具解耦。
前言
在开发多步骤 Agent 的时候,一个很常见的坑:把任务计划完全交给 Prompt 和聊天历史维护。
计划混在对话消息里,多轮之后结构丢失;开启并行工具调用时,不同工具分支会互相篡改进度;简单问答场景还要额外走一轮规划,额外消耗 Token、拉高延迟。
更好的思路不是优化提示词,而是分层: 将待办任务抽成独立的图状态,单独提供一个规划工具,仅支持全量表覆盖更新;在 Agent 循环的边界通过中间件钩子做校验与约束注入;文件读写、接口请求这类业务工具只负责业务副作用,不触碰任务进度。
计划不应该存活在聊天记录中
Agent 的运行循环本身很清晰:组装上下文 → 调用大模型 → 执行工具 → 回填工具结果到消息列表,循环往复直到不再调用工具。
如果计划只是一段文字藏在系统提示或者助手消息里,它只是一段自然语言,程序无法结构化读取、校验、持久化。一旦轮次变多,模型很容易遗忘、遗漏步骤。
可靠的规划能力,需要同时满足三个约束:
- 计划是结构化状态,每一轮推理都能稳定读取;
- 模型可以修改计划,但修改动作具备可判定语义,优先全量替换,而不是零散打补丁;
- 运行层主动拦截会破坏一致性的操作,不完全依赖模型自律。
这三点,分别由扩展状态、规划专用工具、中间件钩子承载。
待办列表:独立的运行时状态
我们在 Agent 的 State 结构中扩展一个 todos 待办数组。 单条待办结构极简,只保留两个核心字段:任务描述、任务状态。 状态枚举仅三类:pending(排队中)、running(处理中)、completed(已完成)。不需要复杂的阻塞、取消分支,不需要额外状态机。不再需要的步骤直接删除即可。
设计上有一个关键点:这份待办状态对调用方透明,用户输入不会携带进度。 用户只输入最终目标,不传入内部任务列表,规避外部伪造完成进度的风险。待办只能通过规划工具写入,并且跟随检查点持久化;线程重启恢复时,进度直接从状态恢复,不用暴力解析历史消息重建计划。
聊天消息中依然会生成一条规划工具的返回回执,用来给模型感知变更;但程序做判断、持久化的数据源,是状态里的结构化待办,而不是消息文本。
采用整表覆盖语义,放弃增量更新
规划工具只支持全量表替换,不提供单条修改、追加接口。
很多人第一反应是增量更新更省 Token,但增量存在致命问题:程序无法区分"参数漏传"和"主动删除步骤";并行发起多次增量更新时,没有天然的合并规则,极易出现状态错乱。
整表覆盖的语义非常干净:工具调用执行完成后,当前待办列表 = 本次传入的完整列表。 如果有已经完成、需要保留的任务,必须原样放在新列表参数中。
工具返回侧需要同时做两件事:
- 覆盖 State 内的 todos 数组;
- 生成一条携带 call_id 的工具结果消息追加到消息链。
只改状态不回消息,模型无法感知变更;只返回文本,运行时缺少结构化依据。调用编号由运行时自动注入,不交给模型控制。
增加校验钩子:同一轮推理,只允许一次规划写入
文件读取、查询类无冲突工具可以安全并行调用。但规划工具的写入点是同一份待办状态,叠加整表覆盖语义之后,同一轮模型输出多条规划调用会产生覆盖丢失------两次全量列表都只是模型各自视角下的计划,程序无法自动合并。
校验逻辑放在模型输出之后、工具执行之前的钩子: 解析本轮助手消息里全部工具调用,如果规划工具出现 ≥2 次,全部拒绝执行,并且针对每一条违规调用分别返回错误回执。
为什么分开返回错误?如果只返回一条统一报错,模型无法匹配哪一次调用失败,会导致 Agent 循环卡住。
只要规划调用 ≤1 次,不做拦截。业务领域工具不受这条限制,可以和单次规划调用共存于一轮。
这个校验逻辑不能下沉到单个工具函数内部:单工具执行视角看不到同一条模型回复里的全部调用集合,只有后置钩子可以拿到完整调用集合。
规划约束注入在模型请求边界,和业务提示解耦
Agent 原有系统提示词,专注描述角色能力、业务规则。 规划协议是独立一层规则:什么时候需要生成计划、状态怎么流转、什么时候跳过规划。
实现方式:每次发起 LLM 请求之前,把规划协议文本追加到原有系统消息末尾;没有系统消息时,规划协议单独作为系统消息。同步、异步链路复用同一套注入逻辑。
这样做的好处:更换规划策略、调整规则,不需要侵入 Agent 主循环本体。
协议规则尽量精简:
- 多目标、多步骤、计划会随结果动态调整时才启用规划;步骤少(≤3步)或者纯问答场景,直接响应,不走规划流程;
- 开始执行步骤前标记为 running;步骤完成后标记为 completed;
- 任务未全部收尾时,列表至少保留一条 running;
- 全部任务完成之后,最终回复单独在下一轮输出,不和最后一次规划写入放在同一轮。
边界划分清晰:提示词定义模型的使用规范;钩子拦截确定性的非法调用。模型是否真的把任务做完,运行时无法100%核验;但一轮多次覆盖这种确定性错误,可以提前拦截。
领域工具和规划工具严格隔离
遵循单一职责:
- 领域工具:只负责外部副作用(读写文件、发起请求、数据库查询),不读取、不修改 todos;
- 规划工具:只负责替换待办状态、生成回执,不操作外部资源。
标准流转流程: 模型更新待办列表 → 标记目标步骤为 running → 调用对应领域工具执行任务 → 拿到工具结果后再次调用规划工具,标记任务为 completed。
领域工具执行失败时,由模型自主修订待办:新增补救步骤,或者删除不再需要的任务项。
模型感知不到中间件和状态实现细节,只能看到工具名称、入参 Schema、工具描述。工具描述文档里写明:整表覆盖规则、合法状态枚举、禁止一轮多次调用、适用/不适用场景。 系统提示每轮提醒,工具描述紧邻决策点,两处重复是刻意设计,强化模型遵守协议。
完整一轮的执行时序
- 请求 LLM 前:中间件拼接规划规则到系统提示;
- 拿到模型返回、解析工具调用集合;
- 钩子校验:规划调用超过一次 → 批量返回错误回执,跳过状态更新;仅一次规划调用 → 覆盖 todos,追加成功回执;
- 其余业务工具按原有逻辑正常执行;
- 携带更新后的消息列表 + 最新状态,进入下一轮循环;
- 持续循环直到模型不再返回工具调用。
工程落地结构:中间件列表注册规划中间件,独立工具列表存放业务工具;状态结构体扩展 todos;规划工具同步/异步入口共用一套覆盖逻辑。
方案取舍与边界认知
这套方案不是万能方案,先说清楚它不保证什么: ✅ 不保证生成的任务计划全局最优; ✅ 不校验外部世界里任务是否真的执行成功;
它真正稳定保证的能力: ✅ 任务进度独立于聊天文本,结构化持久化、可恢复; ✅ 修改计划只有全量覆盖这一种语义,消除增量合并歧义; ✅ 拦截同一轮多次规划写入带来的状态覆盖丢失; ✅ 规划规则统一注入,业务工具和进度管理解耦; ✅ 短问答场景可以直接跳过规划,减少不必要工具往返。
计划本身允许出错、允许动态调整;中间件的职责,是保证计划变更时,状态始终一致、完整、可回溯。