上一篇中,我们在实现基础的状态透传并将其落盘到记忆后,应对简单的单步任务(如“预订西安 10 月 1 日的两晚 suite”)已经绰绰有余。然而剥离了框架的黑盒后,一旦直面真实业务里更复杂的多步复合目标(例如“先查天气,下雨订 suite,否则订 standard”),最棘手的问题便暴露无遗:核心调度循环(Loop)依然在每一轮盲目地“裸猜”下一个 tool_calls。此时大模型的视野极其受限——记忆中虽然持有全局目标和 hotel_ids 等上下文参数,却唯独缺失当前所处节点的状态流转标识(status)。这种上下文失真极易诱发推理幻觉,进而演化为两种典型的翻车:要么无视前置依赖、跳过查天气强行订房(跳步),要么拿到搜索结果后仍陷在死循环里反复触发(重复调用)。
为了对齐并发调度与状态流转,相比于套壳调用庞大且黑盒的现成框架,这篇我们继续坚持“手搓 Agent”的硬核极客风。核心调度循环与工具架构(Schema)的底层逻辑保持不动,唯一的改动是在记忆对象中注入一个显式的状态机——计划(Plan),并配套两个轻量级基础设施工具:set_plan 负责写入或全量替换计划,mark_step 负责精准推动单个节点的状态流转(将 status 置为 done 或 failed)。
在深入代码细节前,为确保认知对齐,本文涉及的核心行话与底层概念均严格遵循以下语义边界:
| 术语 | 底层语义与工程边界 |
|---|---|
| 计划 (Plan) | 固化在持久化记忆(Memory)中的任务步骤列表,统一挂载在 plan 字段下。 |
| 步骤 (Step) | 计划链路中的单一节点,包含 index(索引)、title(意图标识)和 status(当前状态标识)。 |
pending / done / failed | 节点状态机的三种原子状态:等待调度(未执行)、成功闭环(已完成)、调度异常(执行失败)。 |
| 计划工具 | 接管状态机流转的底层维护工具:set_plan 与 mark_step。 |
| 业务工具 | 实际业务逻辑落地的执行函数:如 get_weather、search_hotels、book_hotel 等。 |
| 重试 (Retry) | 计划拓扑结构不变,仅在当前节点回灌修正后的参数,重新发起对同一业务工具的调用。 |
| 重规划 (Replan) | 当前执行路径被彻底阻断,主动唤起 set_plan,生成一份全新的步骤列表替换旧有计划。 |
| 终止 (Abort) | 熔断调度循环,触发兜底机制,既不重试也不重规划。 |
核心调度铁律:探测不到计划存在时,强制挂起业务调用,优先路由至
set_plan阶段;每轮事件循环严格限制只向下推进一个pending状态的步骤;遇到参数校验失败则原地重试;一旦确认步骤逻辑不可达,必须立即mark_step(failed)落盘,随后再拉起set_plan进行重规划。
Table of contents
Open Table of contents
一、剥离计划的上下文:只能按眼前的返回“盲人摸象”
假设当前的工程目标被设定为:“西安 10 月 1 日住两晚。先查当天天气,下雨订 suite,否则订 standard。”
在缺失计划作为执行“罗盘”的情况下,大模型每一轮推理只能窥探到 system 注入的局部记忆,以及上游刚刚抛回的最近一次业务工具执行结果。若记忆中只零散落着原始目标、一堆 hotel_ids 或 rejected 的报错堆栈,模型便会退化为“盲人摸象”,纯粹依靠眼前这帧残缺的上下文去猜测下一个 tool_calls。此时系统极易出现各种不可控的调度崩溃:
- 无视状态依赖,直接越权裸调
book_hotel发起预订,而get_weather压根没进过调度队列; - 陷入逻辑死锁——先执行
search_hotels,随后插入一次get_weather,紧接着又诡异地再次拉起search_hotels; - 异常处理时产生意图漂移——当
get_weather抛出 Error 时,模型没有去修正错误的日期参数,反而“自作聪明”地篡改目标城市去重试搜索。
为了根治上下文丢失引发的幻觉,计划必须确切地写进持久化记忆,并在每一轮系统提示词(system prompt)组装时被全量透传。它的生命周期应当与 hotel_ids 的更新机制完全打平:由底层代码在拦截到计划工具返回报文后,立刻在内存中执行更新。绝对不要企图把这些控制流逻辑糊进 Assistant 角色的 content 对话里——大模型的注意力机制不可控,随着上下文窗口推进,那些松散的文本大概率会在下一轮推理时遭遇上下文截断,甚至被直接忽略。
二、计划本质上是一个持久化的状态列表
在代码实现层面,所谓的计划,打平了看,就是挂载在 memory 对象下的一个结构化步骤列表:
memory["plan"] = [
{"index": 1, "title": "查西安 10-01 天气", "status": "pending"},
{"index": 2, "title": "搜索酒店", "status": "pending"},
{"index": 3, "title": "预订房间", "status": "pending"},
]
这里 status 字段被严格限制在三种离散状态。由于调度循环是纯同步(Synchronous)的,一个完整的流转轮次中只要底层业务工具一返回,当前轮次即刻结束,因此完全不需要引入异步视角的 in_progress 状态来徒增系统复杂度。
有一点必须特别强调:步骤的颗粒度必须严格按照业务工具的物理边界来拆分,切勿以脑海中泛化的自然语言动词去生搬硬套。例如,get_weather 的一次 API 裸调就是一个独立步骤;而 search_hotels 与 book_hotel 是两个职责不同的底层设施,中间必须通过传递 hotel_id 来衔接,因此必须被硬拆成两个物理步骤。至于“把下雨映射成 suite”这种纯粹的逻辑转换,绝对不配单独占用一个节点资源——房型的动态判断应当推迟到系统组装并调用 book_hotel 时,再通过实时读取记忆中的天气快照来完成参数映射。
底层逻辑明确后,我们需要把这些系统约束写死在 system 提示词中,作为大模型调度的硬性守门员:
SYSTEM_PROMPT = (
"没有计划时,必须首先路由至 set_plan 工具,严禁越权直接调用业务工具。"
"在单个执行轮次中,只允许向下推进唯一一个 status=pending 的节点。"
"若步骤成功闭环,则立即触发 mark_step(done);若遇到不可达阻断,则执行 mark_step(failed) 后重新唤起 set_plan。"
"针对参数格式异常或房型未命中枚举的情况:绝对不改动原有计划拓扑,仅修正参数并原地重试。"
"系统内流转的天气、房价、hotel_id 数据,必须且只能来源于工具返回报文或上下文记忆,严禁模型自行脑补数据。"
)
三、利用基础设施工具接管记忆写入
在整个架构流转中,set_plan 是破坏性最强的指令:它专门用于初始化,或在重规划时全量覆盖整份计划,且一旦写入,所有新节点的 status 会被强制重置为 pending。相比之下,mark_step 则精细克制得多,仅负责靶向修改某个单一节点的状态机流转。
这里最容易踩坑的灾难性操作,是滥用 set_plan 去表达“某一步已经做成了”。一旦整份计划遭到全量替换,此前好不容易标记为 done 的历史步骤全都会被刷新回 pending,导致上游业务工具被反复触发,造成可怕的重复调用雪崩。
{
"name": "set_plan",
"description": (
"执行计划的写入或全量替换操作。仅在上下文无计划,或明确需要重规划时触发。"
"steps 数组必须严格遵循执行拓扑顺序列出,每项仅限一句高度概括的自然语言,严禁在此阶段夹带底层工具参数。"
),
}
{
"name": "mark_step",
"description": (
"精准路由并修改指定节点的 status 为 done 或 failed。"
"必须紧跟在关联业务工具返回结果后立刻调用;若状态置为 failed,需同步在 note 字段录入底层异常原因。"
),
}
为了保障系统鲁棒性,底层代码引擎的执行环节必须加上严格的参数校验卡点:传入的 index 必须落在当前加载的计划列表边界内;mark_step 提交的 status 只能在 done 或 failed 之间二选一。一旦发现越权或非法参数传入,引擎必须立刻拦截,并将报错信息原封不动地回灌给大模型——这与拦截 room_type 不在枚举列表里的兜底逻辑如出一辙。
四、剖析“带天气的订房”实战:状态机的按轮流转
剥开黑盒,逐轮审视记忆状态机的底层流转:
第 1 轮调度时,系统记忆里的计划为空,模型别无选择,只能先发起 set_plan 调用。此时计划被成功落盘到记忆中,状态机完成冷启动:
{"plan": [
{"index": 1, "title": "查西安 10-01 天气", "status": "pending"},
{"index": 2, "title": "搜索酒店", "status": "pending"},
{"index": 3, "title": "预订房间", "status": "pending"},
]}
进入第 2 轮,系统上下文中已挂载完整的计划蓝图,指针精准定位到第一个处于 pending 状态的节点——查天气。模型据此生成并发起对 get_weather 的调用。假设本地 Mock 数据透传回 10-01 有雨,底层代码会将这片天气快照硬写入记忆;随后系统自动打补丁,执行 mark_step(index=1, status="done")。
第 3 轮,指针继续下移,命中下一个 pending 步骤——搜索酒店。search_hotels 成功返回报文后,系统记忆随即捕获到映射的 hotel_ids;紧接着再次推进状态,提交 mark_step(index=2, status="done")。
到了最关键的第 4 轮,指针终于抵达 book_hotel。系统在组装请求参数时,根据记忆快照中透传的“下雨”标记,通过内部映射逻辑锁定房型参数为 suite。预订请求一旦落库成功,系统立刻下发 mark_step(index=3, status="done")。至此所有 pending 节点消耗殆尽,调度循环停止索要新工具,大模型顺利吐出最终答复。
然而真实世界的工程实践中,业务工具随时可能抛出 error 乃至宕机。此时最考验架构设计功底的,就是如何在重试、重规划与终止这三条岔路口做出抉择:
| 异常返回类型 | 底层处理策略 | 计划节点变更逻辑 |
|---|---|---|
| 参数格式校验不通过、请求房型脱离枚举域 | 重试 | 维持现有计划拓扑不变,当前步骤死守 pending 状态。下一轮循环中强制模型回灌修正参数,原地再调同一个业务工具 |
| 业务资源不可达(该城市无酒店、该日期天气数据缺失) | 重规划 | 链路被彻底阻断。必须先对当前节点执行 mark_step(failed),强制终结旧流程,再主动拉起 set_plan 重启新规划 |
| 认证鉴权阻断、API 配额击穿、底层代码致命崩溃 | 终止 | 系统进入兜底保护,立刻熔断当前事件循环,绝不陷入无限重试或重规划的泥潭 |
这里务必死记一条铁律:只有“重试”有资格不改动计划拓扑。一旦引擎决定走向“重规划”,必须显式调用 set_plan。千万不能偷懒地把 error 报错信息往 messages 堆栈里一抛就撒手不管,并任由当前步骤继续保持 pending——若不去扭转状态机,下一轮循环中模型依然会死磕在这个 pending 节点上,拿着同一组错乱的参数对着同一个业务工具疯狂轰炸。
五、精简至上:防范上下文膨胀与节点衰减
LLM 原生开发中,每一次推理节点都必然存在概率衰减。假设大模型单步任务成功率能稳定在 95%,可一旦拉长到五步串行连乘,整体可靠性就会暴跌至大约 77%。若脑洞大开,把计划生搬硬套写成“解析城市 → 规范化日期 → 查天气 → 解释天气 → 搜酒店 → 比价 → 下单”,这种严重冗余的微操无疑是灾难性的:每一次无谓的节点拆分,都意味着给系统徒增一次选错工具路由或填错参数的崩溃风险。
对于用户无需直接感知或干预的中间运算态,坚决不要把它们强行具象化为独立步骤。步骤的规模上限应当无限逼近底层业务工具的物理调用总数,而绝非取决于你在自然语言层面能堆砌出多少个华丽的动词。
六、排障指南:当状态机流转脱轨时,优先核对计划快照
| 错误现象(你监控到了什么) | 根因排查与手术定位(先动哪里) |
|---|---|
上下文记忆中计划为空,系统却越权发起了 book_hotel 请求 | 严查 system 提示词:是否强制锁死了“无计划时绝对禁止调起业务工具”的规则护城河 |
| 在单一的调度轮次内,引擎并发裸调了三个业务工具 | 严查 system 提示词:是否有效执行了“单次轮询仅限推进一个 pending 节点”的节流策略 |
记忆中 index=2 的节点明明已落盘为 done,下游却诡异地重新唤起 search_hotels | 追查工具链路:大概率是系统失控误用了 set_plan 暴力覆盖了整份计划快照,或前置链路中漏填了 mark_step 确认动作 |
模型用同一组废弃参数连续三次吃到 error 闭门羹,且该节点死锁在 pending 状态 | 修正状态流转:对于客观上不可达的步骤,必须强制干预执行 mark_step(failed),果断打断大模型执迷不悟的重试死循环 |
get_weather 触发 error 后,大模型竟通过篡改订房城市来企图“瞒天过海” | 纠正重试域:气象数据的缺失理应在日期参数上重试,而非触发南辕北辙的订房重规划 |
| 计划节点极度冗长膨胀,导致调度轮次和报错率指数级飙升 | 重构系统架构:大刀阔斧合并碎片的业务工具,暴力压缩整体计划的拓扑深度 |
完整代码工程实践
整个调度引擎的底层循环架构与前文保持一致。此次迭代的核心破局点在于,记忆层对象中正式挂载了 plan 状态机,同时基础设施层面成功注册了 set_plan 与 mark_step 两个核心计划工具。完整、可一键运行的代码实现已开源至:agent-in-action/part1-handmade-agent/04-agent-planning/agent.py。其中依赖的天气与酒店服务均采用本地 Mock 数据兜底:系统硬编码设定 10-01 为雨天、10-02 为晴朗。
启动本地跑批测试时,务必死死盯住监控日志里的两项核心指标:其一,第 1 轮冷启动循环时,系统是否如期阻断了业务调用并优先路由执行了 set_plan;其二,每当一个业务节点成功闭环后,持久化记忆中映射节点的 status 是否准确无误地落盘为 done。你还可以尝试把原始目标中的日期手动篡改为 10-02 注入系统,去验证最终生成的订单报文中,房型参数是否精准回滚成了 standard。
接下来:打破本地调用的边界
当我们成功将计划状态机固化进上下文记忆,并彻底跑通节点的 status 流转后,当下最头疼的技术债变成了:所有底层业务工具依然需要通过手工造轮子、硬编码的方式,一个个注册进内部的 REGISTRY。在下一篇实战演练中,我们将引入 MCP(Model Context Protocol)协议来彻底击碎这道本地边界,实现外部工具的无缝桥接:通过 list_tools 动态拉取并解析外部服务的 Schema,最终利用 call_tool 完成跨越系统的工具路由与分发执行。
一句话总结核心架构
在底层架构逻辑上,计划就是持久化在记忆中的状态机列表,并被强制要求每一轮都要向
system提示词全量透传。面临系统冷启动(无计划)时,必须优先拦截调用并拉起set_plan进行初始化;无论节点执行成功闭环抑或遭遇不可达阻断,都必须通过mark_step精确修正其status状态机落盘,绝不可将这类关键流转数据草率地丢弃在 assistant 的临时content对话流中。调度引擎的并发铁律是:每个事件轮次只允许向下击穿唯一一个
pending节点。遭遇参数校验异常则原地回灌重试,遇到步骤物理不可达则断臂求生重新触发重规划。工程上,节点链路务必极简,步骤能少则少,严防上下文链路的无限膨胀。