为什么你的 Agent Demo 永远上不了生产?
写一个 Agent Demo 真的不难。
一个模型,几个 tools,再加一个 while loop。模型说要调用工具,你执行工具;工具返回结果,你再塞回模型。顺利的时候,二三百行代码就能跑出一个让人挺兴奋的 demo。
麻烦一般发生在上线后。
比如一个很普通的订单场景:
用户:帮我创建一张订单
↓
Agent 调用 create_order()
↓
接口超时
↓
Agent 认为失败,开始 retry
↓
create_order() 又执行了一次
↓
用户收到两张订单
这时候你很难把锅甩给模型。
模型只是看到了一个超时。真正的问题是:你的系统没有告诉它这次工具调用到底有没有产生副作用,也没有为这次业务意图生成稳定的幂等键,更没有一份 tool ledger 记录'这个 create_order 已经发出去过'。
Agent 没有神秘地变坏。你的 runtime 太薄了。
社区里早就有这些味道了
如果你搜过 LangChain、LangFlow 或各种 agent 项目的 issue,会看到一个反复出现的句子:
Agent stopped due to max iterations.
LangChain 在 2023 年就有人遇到类似问题:Agent 一直尝试工具调用,最后被最大迭代次数拦住。LangChainJS 也有 'agent stuck in a loop' 的 issue,输出里同样出现 max iterations。LangFlow 到 2025 年还有用户报告这个现象。
这不是某个框架'写得不够聪明'。这是 Agent Loop 的基本事实:只要下一步由模型决定,循环就有不收敛的可能。
如果你的系统只有 prompt 和 tools,没有 stop reason、重复调用检测、失败分类、trace 和预算,max iterations 最后就会变成一个很粗暴的保险丝。保险丝当然要有,但保险丝跳了以后,你仍然不知道房子里哪根线短路。
更麻烦的是,Agent 不只是会卡住。它还会一边卡住,一边花钱,一边改东西。
Demo 为什么总是显得很顺
Demo 环境通常太友好了。
工具不会真的失败。即使失败,也只返回一句 'error'。上下文刚好够用。文件很少。用户不会中途取消。模型不会连续三次传错参数。Shell 命令不会挂住。接口不会在'已经创建成功但响应丢失'的尴尬时刻超时。
生产环境不吃这一套。
生产环境的问题更像这样:
- 模型传了一个 schema 上合法、业务上危险的参数。
- 工具返回 500,但副作用可能已经发生。
- 第 17 步时上下文被压缩,用户最开始的限制条件没了。
- 读文件工具吐出两万行日志,直接挤掉了真正重要的 observation。
- Agent 反复调用同一个搜索工具,因为它不知道前两次失败是同一个失败。
- 用户拒绝了高风险命令,但最终回答里看不出它被拒绝过。
- 崩溃恢复后,系统把已经执行过的写操作又跑了一遍。
你看,问题不再是'模型会不会思考'。问题变成了'系统能不能承载一个不稳定的决策源'。
这就是 Agent Runtime 要解决的事。
Tool calling 不是 Runtime
现在模型 API 的 tool calling 已经比早期舒服很多。OpenAI 的文档里,工具调用有明确的 call id,工具输出也可以是结构化 JSON 或普通文本;Structured Outputs 还能让模型输出贴合你给的 JSON Schema。
这些都很有用。
但它们解决的是接口层的一部分问题,不是运行时问题。Schema 能减少'少字段、类型错、JSON 乱飞'这类低级错误,却不能替你判断:
- 这个工具有没有副作用?
- 这次失败能不能重试?
- 第二次调用是不是同一个业务意图?
- 用户是否授权它访问这个目录?
- 输出太大时应该截断、摘要,还是保存成 artifact?
- 工具成功了,但模型最终回答没引用观察结果,要不要拦?
Schema 是门槛。Runtime 是秩序。
这句话我不想说得太玄。落到代码里,其实就是几个很朴素的结构。
type ToolSpec = {
name: string;
description: string;
schema: unknown;
riskLevel: "read" | "write" | "network" | "destructive";
timeoutMs: number;
retryPolicy?: RetryPolicy;
outputPolicy: ToolOutputPolicy;
};
type ToolResult =
| { ok: true; content: string; artifactId?: string }
| {
ok: false;
code:
| "INVALID_ARGUMENTS"
| "NOT_FOUND"
| "PERMISSION_DENIED"
| "TIMEOUT"
| "SIDE_EFFECT_UNCERTAIN";
message: string;
};
很多 demo 不写这些东西,因为写了不炫。可一旦上线,恰恰是这些东西救命。
Retry 最容易骗过工程师
重试看上去是可靠性手段,写起来也简单:
for (let i = 0; i < 3; i++) {
try {
return await tool.call(input);
} catch (err) {
await sleep(1000 * (i + 1));
}
}
这段代码的问题是,它不知道自己在重试什么。
读文件失败了,重试大概率没事。创建订单失败了,重试就要小心。扣款、发券、发邮件、删除文件、提交 PR、执行数据库 migration,这些工具都带副作用。你不能只看异常类型,还要知道'上一次请求有没有被对方收到''副作用有没有发生''这一次 retry 和上一次是不是同一个业务意图'。
AWS Builders Library 专门写过一篇关于 idempotent APIs 和安全重试的文章。Stripe 的 API 文档也明确建议在创建或更新对象时使用 idempotency key,这样网络错误后可以用同一个 key 安全重试,避免创建第二个对象或执行两次更新。
把这个思想搬到 Agent Runtime 里,就是:工具调用不能只是函数调用。它至少要有一份账本。
type ToolLedgerEntry = {
runId: string;
stepId: string;
toolName: string;
inputHash: string;
idempotencyKey?: string;
status: "started" | "succeeded" | "failed" | "uncertain";
sideEffect: "none" | "possible" | "committed";
};
当 create_order() 超时,runtime 不能只把 TIMEOUT 丢给模型。它要把这次调用标记成 SIDE_EFFECT_UNCERTAIN,接下来优先查订单状态,而不是直接再创建一次。
这个细节很土,但生产系统就靠这种土办法活着。
Max iteration 不是停止条件的全部
很多 Agent 框架会提供 maxIterations 或 maxSteps。这东西必须有,但它只是最后一道闸。
更好的 runtime 会记录停止原因:
type StopReason =
| "final_answer"
| "max_steps"
| "max_tokens"
| "timeout"
| "permission_denied"
| "repeated_tool_call"
| "same_error_repeated"
| "side_effect_uncertain";
这比一句'达到最大迭代次数'有用得多。
如果停止原因是 repeated_tool_call,你要查工具描述是不是误导了模型。如果是 same_error_repeated,你要看 observation 有没有给出可修复信息。如果是 max_tokens,问题可能在 Context Builder。如果是 permission_denied,最终回答应该告诉用户哪一步被拒绝,而不是装作什么都没发生。
我见过不少 demo 的循环长这样:
while not done:
call model
if tool_calls:
call tools
else:
done
这不是不能用。它适合教学,适合视频演示,适合第一天证明'模型能调工具'。但它还缺少工程系统最关心的东西:
- 这一步为什么开始?
- 这一步用了哪些上下文?
- 工具调用是否合法?
- 工具结果有没有被截断?
- 有没有重复调用同一个高风险工具?
- 失败后是否应该让模型继续?
- 中断后能从哪里恢复?
这些问题没有答案,Agent Demo 就只能停留在 Demo。
聊天记录不是 Trace
很多团队第一次排查 Agent 问题,会先打开聊天记录。
聊天记录当然有用,但它不是 trace。它通常看不到 timeout、duration、token、工具输入摘要、权限决策、输出截断、artifact id、retry 次数,也看不到'模型为什么没看见某个文件'。
OpenTelemetry 对 trace、span、attribute、event 的划分已经很成熟。一个 span 表示一次有开始和结束的操作,attribute 记录键值信息,event 记录某个有时间点意义的事件。Agent Runtime 没必要重新发明可观测性,但需要把 Agent 专属事实记进去。
我会把一次 run 拆成这几类 span:
agent.run
model.call
context.build
tool.call
permission.decision
checkpoint.save
每个 tool.call 至少记录:
{
toolName: "create_order",
inputHash: "sha256:...",
status: "timeout",
durationMs: 31200,
sideEffect: "uncertain",
retryCount: 1,
idempotencyKey: "order:user_42:cart_998"
}
有了这个,你才知道事故怎么发生。没有它,复盘会变成猜谜。
第一版 Runtime 先做什么
如果你现在手里有一个 Agent Demo,别急着换框架,也别急着把 prompt 改得更长。先补六件事。
第一,定义统一消息结构。用户消息、模型消息、tool call、tool result 分清楚,不要靠字符串拼接糊在一起。
第二,写 Tool Registry。哪怕只有三个工具,也要有 schema、risk level、timeout、retry policy 和 output policy。
第三,所有工具返回结构化结果。失败不要只写 error: string,至少分出 INVALID_ARGUMENTS、NOT_FOUND、PERMISSION_DENIED、TIMEOUT、OUTPUT_TOO_LARGE、SIDE_EFFECT_UNCERTAIN。
第四,加停止原因。maxSteps 要有,但重复工具调用、重复错误、超时、预算耗尽、权限拒绝也要变成明确 stop reason。
第五,给有副作用的工具加 ledger 和 idempotency key。尤其是创建、更新、删除、支付、发消息、执行命令这类工具。
第六,写最小 trace。不要等接入完整观测平台才开始记录。先落 JSON 文件都行,只要能还原一次 run。
做到这里,你的系统未必已经是产品。但它已经不像玩具了。
真正的分水岭
我现在判断一个 Agent 项目有没有进入工程化,基本不看它 demo 视频多顺。
我看这些东西:
- 有没有 stop reason。
- 有没有 tool failure taxonomy。
- 有没有 permission decision。
- 有没有 context budget。
- 有没有 tool ledger。
- 有没有 checkpoint。
- 有没有 trace。
- 有没有 eval 能复现失败。
这些东西听上去没有'多智能体协作'酷,也没有'自动完成复杂任务'好卖。但一个 Agent 真到生产环境,最后天天救火的就是这些模块。
写 Agent Demo 是让模型动起来。
写 Agent Runtime 是让它出错时还能被控制。
我最近在把这套东西整理成《Agent Runtime 工程化》。后面会继续把架构图、代码样例、Production Checklist 和试读章节放出来。下一篇先讲一个最容易吵起来、也最值得讲清楚的问题:
Agent Loop、Harness、Runtime,到底是什么关系?
参考资料
- LangChain issue: "Agent stopped due to max iterations."
- LangChainJS issue: agent stuck in a loop
- LangFlow issue: Agent stopped due to max iterations
- OpenAI Docs: Function calling
- OpenAI Docs: Structured model outputs
- AWS Builders Library: Making retries safe with idempotent APIs
- Stripe Docs: Idempotent requests
- OpenTelemetry Docs: Traces


