上一篇文章,我们通过新增 500 行代码,为 AI Agent 实现了 Skills 的渐进式加载,这种工程化思路体现了人类对复杂系统的管理智慧。然而,在测试复杂任务时,当 Agent 从 Demo 走向实际生产任务时,最初的 while(true) 循环带来的简洁,逐渐演变为维护和扩展的噩梦。本文将记录如何通过 800 行代码完成一次架构升级,并分享背后的设计推导过程。

问题的起点
我们最初的 Agent 核心逻辑只有一个简单的循环:
while (true) {
const response = await llm.chat(messages);
if (response.hasToolCalls) {
await executeTools(response.toolCalls);
continue;
}
return response.content;
}
这不足 10 行的代码支撑了早期的所有功能,简洁、优雅且一目了然。它本质上是一个隐式的状态机,许多早期 Agent 框架(如 LangChain)也采用了类似实现。但这种方式的局限性随着复杂度增长而暴露无遗。
那么,具体问题是什么呢?
四个迫使架构升级的真实场景
场景一:用户问“它卡住了吗?”
测试同学跑来问:“Agent 运行了两分钟没有反应,是卡住了还是在处理?”
我看了看代码,发现一个尴尬的事实:我也不知道。while 循环内部是一个黑盒,无法判断 Agent 当前是在等待 LLM 响应、正在执行工具,还是真的发生了阻塞。这暴露了 状态不可观测 的核心痛点。虽然可以通过打日志临时解决,但缺乏优雅性和系统性。
场景二:LLM 陷入工具调用死循环
一次测试中,Agent 反复调用同一个 readFile 工具,循环了 200 多次后才因 Token 超限报错。
[工具调用] readFile -> 成功
[工具调用] readFile -> 成功
[工具调用] readFile -> 成功
... (重复 200 次)
[错误] token 超限
LLM 有时会出现“逻辑鬼打墙”,而原始的 while(true) 循环没有任何防护机制。这揭示了 缺乏自我保护与限制机制 的痛点。增加一个 MAX_RETRY 常量可以缓解,但仍是治标不治本。
场景三:前端状态显示不同步
Web UI 上有一个 loading 指示器。但问题在于,有时后端 Agent 已经返回结果,前端的指示器却还在旋转。排查发现,依赖事件驱动(如 assistant_end)的机制是脆弱的,事件可能丢失,前端无法主动查询当前的确切状态。这带来了 事件驱动模型的脆弱性与状态同步机制缺失 的问题。
场景四:难以扩展“反思-重试”等复杂流程
产品提出新需求:在工具执行后,让 Agent 能反思结果是否正确,若不正确则自动重试或调整计划。
我盯着 while 循环中的代码块思考:
if (response.hasToolCalls) {
await executeTools(response.toolCalls);
// 反思逻辑加在这里?
// 重试逻辑呢?重新规划呢?
continue;
}
逻辑当然能硬塞进去,但代码会迅速变成一团“意大利面”。这种“反思-重试-重新规划”的流程,本质上是一个 状态流转 问题,强行塞入单一循环会严重破坏代码的可读性和可维护性。这里的痛点是 架构扩展性差,难以支撑日益复杂的智能流程。
设计推导:我们究竟需要什么?
从上述四个场景中,我们可以提炼出架构升级的核心需求:
| 场景 |
提炼出的需求 |
| “卡住了吗?” |
状态可观测 |
| 工具调用死循环 |
执行限制机制 |
| 前端状态不同步 |
状态可主动查询 |
| 增加反思功能 |
灵活的流程控制 |
要系统性地满足这些需求,一个经典的设计模式浮出水面:有限状态机(Finite State Machine, FSM)。
状态机设计:Plan - Execute - Critique
你可能会问,为什么选择这三个核心阶段?这其实是借鉴了人类解决问题的方式:
- Plan(规划):拿到任务,先思考策略与步骤。
- Execute(执行):按照既定计划执行操作。
- Critique(评估/反思):对执行结果进行评估,决定下一步行动。
这并非独创,而是 AI Agent 领域的经典范式(如 OpenAI 的 Function Calling、ReAct 框架)。我们在此基础上的思考是:如何设计一个可扩展的 状态机 实现。
状态流转图

状态粒度:为何要拆解这么细?
或许你会质疑:用 PLANNING、EXECUTING、COMPLETED 三个粗粒度状态不够吗?
答案是:状态的粒度直接决定了系统的可观测性与可控性。
举例来说,如果只有一个 EXECUTING 状态:
- 当被询问“在干嘛”时,你只能回答“在执行中”,信息价值低。
- 难以针对 LLM 推理或工具执行等不同阶段设置独立的超时控制。
- 没有合适的“钩子”在 LLM 响应后、工具执行前插入特定逻辑。
拆分为细粒度状态后:
- STREAMING:正在接收 LLM 的流式响应。
- PROCESSING:正在解析 LLM 响应内容,判断意图。
- TOOL_EXECUTING:正在调用并执行外部工具。
每个状态都有明确的语义边界,可以独立监控、配置(如超时)和扩展。这里的 核心设计原则 是:状态的粒度应与你需要施加控制的粒度相匹配。
关键:Critique 阶段的决策逻辑
Critique 阶段是整个状态机设计的核心,它负责回答一个关键问题:当前步骤执行完毕后,下一步应该做什么?
我们将其抽象为一个决策对象:
{
"action": "continue" | "retry" | "replan" | "complete",
"reason": "判断依据",
"confidence": 0-100
}
四种决策对应了不同的后续流程:
| 决策 |
适用场景 |
下一个状态 |
| continue |
工具执行成功,继续后续步骤 |
STREAMING |
| retry |
工具执行失败,应重试当前步骤 |
STREAMING |
| replan |
发现原有计划有问题,需重新规划 |
PLANNING |
| complete |
任务已达成或无需继续 |
COMPLETED |
这个设计使 Agent 具备了基础的“反思”与“规划调整”能力,不再是机械地执行到底,而是形成了一个“评估-决策-行动”的闭环。
限制机制:构建三道防线
针对“死循环”问题,我们在状态机的基础上构建了三道防线:
const Limits = {
maxLoops: 50, // 最大状态循环次数
maxToolCalls: 100, // 最大工具调用次数
timeoutMs: 5 * 60 * 1000, // 总超时时间(5分钟)
};
为什么选择这三个维度的限制?
- 循环次数 (
maxLoops):防止状态机逻辑进入死循环(例如 Plan → Execute → Critique → Plan... 无限循环)。
- 工具调用次数 (
maxToolCalls):防止对某个工具(如读文件)的滥用或无限调用。
- 总超时时间 (
timeoutMs):最终的兜底保护,确保任何任务都不会无限期挂起。
参数值(50/100/5分钟)的依据是什么?
这来源于对历史任务运行的观察和经验:
- 约 90% 的任务在 10 个状态循环内完成。
- 约 99% 的任务在 30 个循环内完成。
- 工具调用次数通常为状态循环次数的 1-3 倍。
因此,将上限设置为 50 次循环和 100 次工具调用,足以覆盖绝大多数正常场景。一旦超过,系统可以安全地假设出现了异常情况并终止任务,这体现了 设计上的自我保护 思想。
何时该引入状态机?设计原则总结
一个关键的设计原则是:事件(Event)用于通知过程,状态(State)用于确认结果。两者结合才能构建可靠系统。
不需要状态机的情形:
- 快速原型(Demo / PoC)验证。
- 简单的单轮问答对话。
- 对可观测性、控制性无要求的场景。
需要引入状态机的情形:
- 生产环境部署。
- 处理复杂的多步骤、多轮次任务。
- 需要为 UI 提供明确的状态展示。
- 需要内置安全限制与防护机制。
- 预见未来会有复杂的流程扩展需求。
写在最后
从 while(true) 到显式状态机,本质上是一次架构思维的跃迁:从 “能够运行” 转向追求 “可观测、可控制、可扩展”。
这 800 行代码的投入,换来了:
- 清晰的系统状态,终结了“是否卡住”的疑问。
- 内置的防护机制,避免了失控的死循环。
- 灵活的扩展能力,新增功能往往只需增加或调整状态与流转逻辑。
当智能 Agent 的系统复杂度增长时,其底层架构必须同步演进。这次重构实践不仅解决具体问题,更是一次关于如何为智能系统设计健壮控制流的 开源实战 思考。如果你对从零开始构建 AI Agent 并深入其工程化细节感兴趣,欢迎在 云栈社区 交流探讨。本文涉及的代码已在小范围分享,其目的并非提供一个生产级 Agent,而是通过动手实现来摸透各类工程化模式背后的“为什么”,这对于理解智能体系统的本质至关重要。