手写智能体循环,掌握执行与故障边界
作者以约 300 行代码构建智能体,围绕工具注册、状态管理和反复决策组织执行流程,并设置步数与时间预算。文章讨论可观测性收益,以及自行维护重试、超时和日志的成本。
AI 深度解读
Agent 最难排查的,往往是模型为什么又调用了一次工具、为什么把空结果当成结论。作者从 2023 年尝试框架后,连续三个 Agent 都选择手写执行循环:第一版约 300 行,每次决策都能打印出来。
这篇文章的价值在于把可靠性拆成了具体工程问题:如何限制执行、保存事实、表达错误,以及复现失败。作者的结论是先把循环做透明,再按实际需求引入框架和 MCP。不过,文中的效果数字来自个人项目,示例代码也有需要补齐的地方。
先管住循环,模型才有纠错的机会
作者的 Agent 只有一条主线:让模型选择动作,调用工具,把结果写回状态,再让模型决定下一步。框架里的规划器、记忆和图编辑器,都可以等产品证明需要它们时再加。
代价同样明确:重试、超时、日志要自己维护,作者估算约有 200 行这类代码。好处是异常堆栈直接指向自己的实现,便于追查决策过程。
下面这段循环展示了三个关键约束:最多执行 8 步、设置 45 秒期限,并把部分工具异常转成数据交给模型处理。
import json, time
MAX_STEPS = 8
DEADLINE = time.monotonic() + 45
def run(user_message, tools, model):
state = {"messages": [{"role": "user", "content": user_message}],
"tool_results": [], "steps": 0}
while state["steps"] < MAX_STEPS and time.monotonic() < DEADLINE:
state["steps"] += 1
action = model.next_action(state, tools.schemas())
if action["type"] == "final":
return action["content"]
name = action["name"]
if name not in tools:
state["tool_results"].append({"error": f"unknown tool: {name}"})
continue
try:
result = tools[name](**action["arguments"])
except TypeError as exc: # bad arguments
result = {"error": f"invalid arguments: {exc}"}
except TimeoutError:
result = {"error": "tool timed out after 10s"}
state["tool_results"].append({"tool": name, "result": result})
return {"error": "step budget exhausted", "steps": state["steps"]}
要留意 DEADLINE 的位置:它在函数外初始化,多次调用会共享同一个截止时间。另外,循环只在进入下一步前检查时间,无法中断正在阻塞的模型请求或工具调用。捕获 TimeoutError、返回「10s」错误文案,也不等于实现了 10 秒超时。
步数限制和时间限制必须同时存在,而且要落实到实际调用层。
作者认为 8 步足够完成搜索、详情、比较和回答,但这只是其业务经验。他曾观察到模型连续调用同一工具 11 次。把错误写回状态后,失败会话减少了一半以上,这是作者报告的效果,原文没有给出样本量或对照方法。
状态里要存事实,工具要解释失败
作者认为,约 30 行的循环只是骨架,状态设计才决定 Agent 能否调试。他在文字中描述了消息、工具结果和已确认事实三类信息,但示例初始化的是 messages、tool_results、steps,没有展示 facts 的创建过程。
事实区的用途很具体:从返回值中提取 hotel_id,后续提到「刚才那家酒店」时,能找到确定的标识。每轮再把事实压缩成四五行,避免反复回放整段对话。这里的「确认」指工具结果已有对应数据,不代表这些数据已经经过独立核验。
工具接口则先写 JSON Schema,再写函数。以 search_hotels(place: str, check_in: str, nights: int = 2) 为例,说明里要交代返回酒店 ID、名称、每晚价格和币种;参数中 place、check_in 必填,日期说明为 YYYY-MM-DD,nights 限制在 1 到 14。
这类约束能减少明显错误,但描述日期格式不等于执行日期校验,Schema 仍需要工具层落实。
作者遇到的三个故障,都与状态和接口有关:
- 同一城市被搜索四次,仅日期字符串空格不同。作者用参数哈希去重;若要识别这类差异,还需要先规范化参数,原文没有展开。
- 过去的日期让工具返回
{"hotels": []},模型却回答「那个周末没有房」。修复方式是让空结果携带明确原因,区分无库存与请求失败。 - 五家酒店连同完整设施列表约占 4,000 tokens,调用三次后上下文大半变成 JSON。作者只保留所需的八个字段,图片、描述和嵌套政策对象先去掉,用户追问时再查详情。
模型能否纠错,取决于工具是否把失败原因表达清楚。
MCP 接走重复适配,回放负责验证行为
手写循环不代表所有接入代码都要自己写。作者在第二个 Agent 开发途中,发现鉴权头、重试、响应映射和错误文案已经重复写到第三遍,于是引入 MCP:服务端维护工具接口,自己继续掌握执行循环。
旅行项目使用的是下面这段配置:
{
"mcpServers": {
"rollinggo-hotel": {
"type": "http",
"url": "https://mcp.rollinggo.ai/mcp",
"headers": { "Authorization": "Bearer YOUR_API_KEY" }
}
}
}
这里的 mcpServers、type、url、headers 定义接入方式,Authorization 中的 YOUR_API_KEY 需要替换。作者报告服务提供 searchHotels、getHotelDetail、getHotelSearchTags 三个工具,并负责参数校验和类型化错误;这些是该服务的实现能力,不能直接推广到所有 MCP 服务。
据作者描述,三个项目累计 1,900 行适配代码,缩减为 240 行格式化和错误处理。原文还提到一份 5 分钟 redirect mode 指南,以及无需自行处理支付的预订交接,但没有展开交接机制。
验证方面,作者记录每一步的模型动作、工具名、参数、结果哈希和延迟,再用 40 条真实轨迹回放。断言检查结果是否满足要求,例如酒店 ID 是否存在于测试数据、是否重复调用同一工具和相同参数,而非要求回答措辞一致。
作者称,这 40 条轨迹捕获了过去六个月中其发布的全部回归问题。这支持了回放的实用价值,但不足以证明单元测试没有作用。
我的判断
我赞同先让执行路径可见,再决定引入多少抽象。对工具少、流程短的项目,这种做法能让开发者直接观察一次错误如何影响下一次决策。它提供的是一个容易理解的起点,不能据此认定生产级 Agent 只需 300 行。
原文没有提供与框架方案的开发成本、稳定性或延迟对照,因此「不用框架更好」仍是经验之谈。团队如果已经依赖框架的追踪、重试和流程管理能力,维护一套自写实现也会产生持续成本。
作者计划补上写工具确认门,以及落盘的结构化日志。预订、支付、删除前要求用户明确确认很合理;但「目前没有写操作,所以安全」说得过满,只读调用仍有权限与数据范围问题。凌晨 2 点排障需要完整轨迹的判断,也恰好说明可观测性应尽早建设。
能拿来做什么
- 用 Node 开发工具调用 Agent 时,先集中实现循环、状态和工具注册表,同时为单次请求设置步数上限、总期限及实际调用超时。
- 把工具返回值定义为结构化数据,明确区分成功、无结果和失败原因;将已选对象的 ID 存入事实区,减少从聊天记录中重新猜测。
- 保存真实失败会话,优先回放重复调用、错误日期和空结果误判,按业务结果写断言,并用单元测试验证参数处理等确定性逻辑。
- 当多个项目反复维护同一套鉴权和响应映射时,再评估 MCP 接入;涉及预订、支付或删除的工具,应在执行前增加用户确认步骤。