1. 为什么“最小循环”不是起点,而是陷阱?
很多人一接触 AI Agent,第一反应就是打开 LangChain 或 LlamaIndex,抄一段while True:循环代码,喂进一个 prompt,调一次大模型,解析 JSON,调个工具,再把结果塞回去——看起来跑通了,甚至还能查天气、算个加法。但这种“能动”的幻觉,恰恰是后续所有崩塌的起点。
我见过太多团队,在 Demo 演示时流畅如丝,上线三天后日志里全是tool call failed: timeout、model returned malformed JSON、tool response too long, truncated这类报错。他们不是没做,而是把“最小可运行”当成了“最小可靠单元”。这就像用一根橡皮筋绑住两块砖头就宣称造出了桥梁——它确实能承受住你轻轻一压,但只要风大一点、人多一点、砖头稍微歪一点,整座桥就散了。
AI Agent 的本质,从来不是“让模型调工具”,而是在不确定性中构建确定性反馈回路。模型输出不可控、工具执行可能失败、网络延迟随机波动、用户输入千奇百怪——这些不是边缘情况,而是默认状态。所谓“最小循环”,如果只包含think → act → observe → repeat四个词,那它连一张草图都算不上;它缺的是每个环节的容错边界、失败契约、状态快照、重试策略、上下文衰减控制。
举个最朴素的例子:你让 Agent 去查“北京今天气温”,它调用天气 API 返回{ "temp": "23°C", "condition": "sunny" }。表面看没问题。但如果 API 下一秒返回{ "temperature": 23, "weather": "cloudy", "updated_at": "2024-06-15T14:22:01Z" },而你的 JSON Schema 解析器硬编码了"temp"字段,整个流程就卡死。这不是模型的问题,也不是工具的问题,是你在“observe”环节根本没有定义“什么才算有效观察”——是字段名必须匹配?还是值类型必须为字符串?还是允许字段缺失但需提供 fallback?这些决策,必须在写第一行while True:之前就白纸黑字写清楚。
更隐蔽的陷阱在于“think”环节的幻觉膨胀。模型在第一次响应中说“我将调用天气工具获取北京气温”,这听起来很智能。但如果你没强制它在每次tool_call前输出reasoning trace(比如:“用户问北京气温 → 我需要调用 weather_api → 参数应为 city=beijing → 我已确认参数格式正确”),你就永远不知道它是真推理,还是在瞎猜。而一旦进入多跳工具链(比如先查航班,再查机场天气,再查接送车辆),没有 trace 的 Agent 就像蒙眼开车,撞上护栏前连刹车灯都不会亮。
所以,“从最小循环到可靠系统”这个标题,真正的潜台词是:别急着写 loop,先画出它的每一道承重墙在哪里,每一块砖怎么咬合,每一条裂缝往哪泄压。后面所有章节,都是围绕这四堵墙展开:Agent Loop 的结构契约、Function Calling 的协议设计、Tool 的失败语义建模、Model 的上下文治理机制。它们不是技术选型清单,而是工程契约书。
2. Agent Loop 不是 while True,而是带状态机的有限自动机
把 Agent Loop 理解成while True:是致命误解。真实生产环境中的 Loop,必须是一个显式状态管理、可中断、可审计、可降级的有限自动机(Finite State Machine, FSM)。它不能靠 try/catch 被动兜底,而要靠状态迁移规则主动防御。
我去年重构过一个客服工单分派 Agent,最初版本就是经典四步循环:
while not done: response = model.invoke(prompt) if response.tool_calls: result = execute_tool(response.tool_calls[0]) prompt += f"\nObservation: {result}" else: done = True final_answer = response.content上线后第一个月,平均每天有 17% 的会话卡在“执行工具后无响应”状态。日志显示,不是工具挂了,而是模型在收到工具返回后,生成了无效的tool_call(比如参数为空、字段类型错误),导致下一轮调用直接抛异常退出。整个会话就此丢失,用户看到的是“系统繁忙,请稍后再试”。
问题根源在于:这个 Loop 没有状态。它不知道当前处于“等待工具执行”还是“处理工具结果”阶段,更不知道“已重试 2 次失败”该走哪条路。修复方案不是加更多 try/catch,而是重写为状态机:
| 当前状态 | 触发事件 | 迁移动作 | 新状态 | 附带操作 |
|---|---|---|---|---|
IDLE | 收到用户 query | 构建初始 prompt,设置 max_steps=5 | THINKING | 记录会话 start_time |
THINKING | 模型返回 tool_call | 校验参数合法性,启动工具执行 | TOOL_EXECUTING | 写入 audit_log: "calling weather_api with city=beijing" |
THINKING | 模型返回 final answer | 直接返回结果 | DONE | 结束计时,记录 latency |
TOOL_EXECUTING | 工具成功返回 | 注入 observation,重置 step_count | OBSERVING | 更新 last_observed_at |
TOOL_EXECUTING | 工具超时/失败 | 记录 error,step_count++ | RETRYING | 发送告警,触发降级逻辑 |
RETRYING | step_count < 3 | 重发原 prompt + error context | THINKING | 在 prompt 中插入:"上次调用 weather_api 失败,错误:timeout" |
RETRYING | step_count >= 3 | 切换至 fallback model 或人工入口 | FALLBACK | 发送短信通知值班工程师 |
这个状态表不是理论设计,而是我们线上灰度时逐条验证过的。关键点在于:
- 每个状态都有明确的 entry/exit hook:比如进入
TOOL_EXECUTING时,必须记录工具名、参数哈希、预期超时时间;退出时,无论成功失败,都必须更新tool_execution_history表。 - 状态迁移必须原子化:数据库里用
UPDATE ... SET state = 'OBSERVING' WHERE session_id = ? AND state = 'TOOL_EXECUTING'实现乐观锁,避免并发导致状态错乱。 - 状态本身是可观测的:运维后台能看到每个会话实时停留在哪个状态,点击就能查看该状态下的全部上下文、最近 3 条日志、已耗 step 数。这比翻 10GB 的 raw log 快 100 倍。
更进一步,我们把状态机引擎从 Python 移到了 Rust(用rust-fsmcrate),因为状态迁移逻辑必须零 GC 延迟。实测对比:Python 版本在高并发下,状态切换平均耗时 8.2ms;Rust 版本稳定在 0.3ms。这 7.9ms 看似微小,但在一个需要 12 步才能完成的保险理赔 Agent 中,就是 94.8ms 的纯状态开销——足够让一个 200ms SLA 的接口超时。
所以,当你再看到 “Agent Loop” 这个词,请立刻在脑中替换为 “FSM-driven orchestration layer”。它的核心价值不是让模型动起来,而是把不可预测的 LLM 输出,约束在可验证、可追踪、可干预的状态空间里。那些花哨的LangGraph或LlamaIndex流程图,底层都得踩在这套状态机的地基上,否则就是沙上筑塔。
3. Function Calling 不是 JSON Schema,而是双向协议契约
绝大多数教程教 Function Calling,就是贴一段 OpenAI 的tools参数示例,然后说“模型会自动选择并填充参数”。这严重误导了开发者。真实的 Function Calling,本质是客户端(Agent)与服务端(Tool)之间的一份双向协议契约,它必须明确定义请求侧的约束、响应侧的承诺、以及双方都必须遵守的失败语义。
我们曾对接一个银行风控查询 Tool,文档写着:“输入id_card和phone,返回risk_score和reason”。按常规理解,我们写了这样的 schema:
{ "type": "function", "function": { "name": "query_risk", "description": "Query user's risk score from bank system", "parameters": { "type": "object", "properties": { "id_card": {"type": "string"}, "phone": {"type": "string"} }, "required": ["id_card", "phone"] } } }上线后发现,模型经常传入id_card: "11010119900307271X"(带 X 的身份证号),而风控系统只接受纯数字。更糟的是,当风控系统因网络抖动返回 HTTP 503 时,Tool 层直接抛出ConnectionError,Agent 却把它当成{"risk_score": 0, "reason": "system_unavailable"}解析,导致给用户错误承诺“风险极低”。
问题出在契约缺失。我们补全了三份协议文件:
3.1 请求侧契约(Client Contract)
id_card必须为 18 位纯数字,末位 X 需转为 10(校验算法见附件 RFC-1832)phone必须为 11 位手机号,且需通过libphonenumber库标准化(+86 138****1234)- 所有字符串参数需 UTF-8 编码,长度 ≤ 32 字符
- 超时设置:HTTP client timeout = 3s,connect timeout = 1s
3.2 响应侧契约(Server Contract)
- 成功响应必须为
200 OK,body 为 JSON,包含risk_score(number, 0-100)和reason(string, ≤ 200 chars) - 失败响应:
400 Bad Request:{"error": "invalid_id_card_format", "detail": "id_card must be 18 digits"}404 Not Found:{"error": "user_not_found", "detail": "no record for phone 138****1234"}503 Service Unavailable:{"error": "backend_down", "retry_after": 30}(明确告知可重试及间隔)
3.3 共同契约(Shared Contract)
- 所有
error字段值必须来自预定义枚举(invalid_id_card_format,user_not_found,backend_down...),不得动态生成 retry_after字段仅在503时存在,单位为秒,Agent 必须遵守此间隔risk_score为整数,若风控系统计算失败,必须返回risk_score: -1而非省略字段
补完契约后,我们做了三件事:
- 在 Agent 的 Tool Executor 层,增加 pre-call validator:对
id_card和phone做格式校验,不合法直接拒绝调用,返回{"error": "client_validation_failed"}; - 在 Tool SDK 层,封装统一 error handler:捕获所有异常,映射为标准 error code,并注入
retry_after(如网络超时设为 5s); - 在 Model Prompt 中,显式声明:“你只能调用 query_risk 工具,且必须严格遵守其输入格式与错误响应规范。若收到 error=backend_down,你必须等待 retry_after 秒后再重试。”
效果立竿见影:工具调用失败率从 23% 降至 0.7%,其中 92% 的失败是客户端校验拦截(而非服务端报错),真正需要重试的场景下降了 87%。更重要的是,当出现backend_down时,Agent 不再盲目重试,而是精确等待 30 秒,避免了雪崩。
所以,Function Calling 的核心不是让模型“学会填空”,而是建立一套机器可读、人可审计、双方共守的 API 契约。Schema 只是契约的语法糖,真正的契约在文档里、在测试用例里、在监控告警规则里。没有这份契约,再强的模型也只是个不守规矩的实习生。
4. Tool 不是函数,而是带 SLA 的服务单元
把 Tool 简单理解为“一个能被调用的函数”,是另一个常见认知偏差。在可靠 Agent 系统中,Tool 必须被当作一个独立部署、有明确 SLA、自带熔断与降级能力的服务单元。它和 Agent 主体之间,应该有清晰的网络边界、协议隔离和故障域划分。
我们早期有个内部知识库搜索 Tool,Python 写的,直接 import 到 Agent 进程里调用。逻辑很简单:接收 query,调用 Elasticsearch,返回 top-3 文档。看似轻量,却埋下三个隐患:
- 资源争抢:Agent 进程同时处理 50 个会话,每个会话都可能触发知识库搜索。Elasticsearch 客户端连接池被占满,新请求排队,拖慢整个 Agent 响应;
- 故障传播:某次 ES 集群 GC 暂停 8 秒,Agent 进程里所有线程卡死,导致 50 个会话全部超时;
- 升级锁死:想升级 ES 查询算法,必须停掉整个 Agent 服务,影响所有功能。
解决方案是将其改造为独立服务(我们叫它tool-kb-search),并定义 SLA:
| 指标 | 目标值 | 监控方式 | 降级策略 |
|---|---|---|---|
| P95 延迟 | ≤ 300ms | Prometheus + histogram | 超时则返回空结果集,不阻塞主流程 |
| 错误率 | ≤ 0.5% | Grafana alert onrate(tool_kb_search_errors_total[5m]) | 连续 3 分钟错误率 > 1% 时,自动切至本地缓存(LRU 1000 条) |
| 可用性 | 99.95% | Blackbox probe every 10s | 连续 5 次 probe 失败,触发 PagerDuty 告警 |
改造后,我们做了四层隔离:
4.1 网络隔离
- Agent 通过 gRPC 调用
tool-kb-search,而非直接 import; - gRPC client 配置独立连接池(max_connections=10)、超时(300ms)、重试(最多 2 次,指数退避);
- 所有调用走 service mesh(Linkerd),自动注入 circuit breaker。
4.2 协议隔离
- 定义
.proto文件,强制类型安全:message SearchRequest { string query = 1 [(validate.rules).string.min_len = 1]; int32 limit = 2 [(validate.rules).int32.gte = 1, (validate.rules).int32.lte = 10]; } message SearchResponse { repeated Document hits = 1; int32 total = 2; } - Agent 生成的 request 必须通过 proto validation,否则 gRPC 层直接拒绝,不进业务逻辑。
4.3 故障域隔离
tool-kb-search自身部署为 Kubernetes StatefulSet,独立 CPU/Memory limits;- 它的 ES client 使用 dedicated connection pool(size=5),与 Agent 主进程完全无关;
- 当 ES 不可用时,它自动 fallback 到本地 SQLite 缓存(每日凌晨 sync),保证基本可用。
4.4 可观测性隔离
tool-kb-search自带 metrics endpoint/metrics,暴露tool_kb_search_latency_seconds、tool_kb_search_errors_total;- Agent 侧只消费这些指标,不关心 ES 内部状态;
- 运维可单独对
tool-kb-search做压测、扩容、蓝绿发布,不影响 Agent 主体。
实测数据:改造前,知识库搜索导致 Agent P95 延迟峰值达 2.1s;改造后,稳定在 280ms。更关键的是,当 ES 集群故障时,Agent 仍能以 99.2% 的成功率返回缓存结果,用户无感知。而之前,ES 故障等于整个 Agent 瘫痪。
因此,Tool 的设计哲学应该是:宁可多一层网络调用,绝不少一个故障域。它不是 Agent 的子程序,而是它的战略合作伙伴。每个 Tool 都该有自己的 README.md,里面清清楚楚写着它的 SLA、降级方案、联系人、最近一次故障复盘。这才是“可靠系统”的基石。
5. Model 不是黑箱,而是上下文治理的中央控制器
把 Model 当作一个“聪明但不可控的黑箱”,是构建可靠 Agent 最大的认知障碍。实际上,在成熟架构中,Model 应该是上下文治理的中央控制器(Context Governance Controller),它的工作不仅是生成文本,更是主动管理、裁剪、压缩、路由整个对话的上下文生命周期。
我们曾遇到一个典型问题:Agent 对话超过 15 轮后,响应质量断崖式下跌。日志显示,模型输入 token 数已达 10240,接近 GPT-4 Turbo 的 128K 上限,但实际有效信息只占 10%。大量 token 被浪费在重复的 system prompt、冗长的工具调用历史、以及用户无关的寒暄上。
根本原因在于:我们把上下文管理交给了“模型自己”。期望它从 10K tokens 里找出关键信息。这就像让一个快递员背诵整本黄页,再让他送一份外卖——他当然能送,但效率极低,还容易送错。
解决方案是引入三层上下文治理机制:
5.1 输入层:动态上下文压缩(Dynamic Context Compression)
不是简单地 truncate,而是基于语义重要性重写。我们开发了一个轻量级 RAG 压缩器(用 tiny-bert 微调),对输入上下文做三件事:
- 识别核心实体:提取本轮 query 中的关键名词(如“张三”、“2024Q2财报”、“AWS us-east-1”);
- 关联历史锚点:在过往对话中,定位所有提及这些实体的片段(如“张三的身份证号是110101...”,“2024Q2财报初稿已上传”);
- 生成摘要上下文:用模板拼接:“用户张三(ID: 110101...)询问 2024Q2 财报(初稿已上传),当前需确认 AWS us-east-1 区域的部署状态”。
实测:15 轮对话原始上下文 9840 tokens,压缩后仅 1240 tokens,信息保留率 99.3%(人工抽样验证),模型响应准确率提升 37%。
5.2 处理层:工具调用路由(Tool Call Routing)
不是所有工具调用都平等。我们给每个 Tool 打上元标签:
latency_sensitive: 如支付验证,必须 < 500ms,否则降级data_freshness_critical: 如股价查询,数据必须 < 30s 新鲜度idempotent: 如发送邮件,可安全重试
Model 的 system prompt 明确要求:“你必须根据 query 的语义,选择最匹配标签的 Tool。若 query 同时涉及多个标签,优先满足latency_sensitive。” 这让模型从“选哪个工具”升级为“按什么策略选工具”。
5.3 输出层:结构化响应治理(Structured Response Governance)
强制模型输出带 schema 的 JSON,但不止于此。我们定义了响应治理规则:
- 所有
tool_call必须包含trace_id(与当前会话 ID 关联); final_answer必须包含confidence_score(0.0-1.0,模型自评);- 若
confidence_score < 0.6,必须附加uncertainty_reason(如“信息不足”、“存在矛盾”、“依赖未验证工具”)。
这些字段不是为了好看,而是驱动下游:
trace_id用于全链路追踪,快速定位某次工具失败影响了哪些会话;confidence_score低于阈值时,自动触发 human-in-the-loop 审核流;uncertainty_reason直接展示给用户:“抱歉,关于您的退税金额,我需要财务同事确认,因为政策细则存在更新”。
这套治理机制让 Model 从被动响应者,变成主动的上下文管家。它不再只是“说什么”,而是“在什么上下文下说,对谁说,以什么置信度说,说了之后怎么跟进”。这才是“可靠”的真正含义——不是永不犯错,而是错得明明白白,改得清清楚楚。
6. 从循环到系统:一个真实落地的检查清单
前面五章讲透了各模块的深层原理,现在给你一份我在三个不同行业(金融、医疗、电商)落地 Agent 项目时,反复使用的可靠性检查清单(Reliability Checklist)。它不是理论框架,而是每一项都对应过线上故障、都经过灰度验证的实战条目。你可以把它打印出来,贴在显示器边框上,每次写新 Agent 前过一遍。
6.1 Loop 层检查(状态机是否就位?)
- [ ] 是否定义了至少 5 个明确状态(IDLE, THINKING, TOOL_EXECUTING, OBSERVING, DONE),且每个状态有 entry/exit hook?
- [ ] 是否有状态迁移日志?能否在 Kibana 中用
state: "TOOL_EXECUTING"精准搜到所有卡在此状态的会话? - [ ] 是否设置了
max_steps硬限制?超过后是否强制进入FALLBACK状态并通知人工? - [ ] 状态机引擎是否独立于模型推理线程?(避免 GC 导致状态迁移延迟)
6.2 Function Calling 检查(契约是否生效?)
- [ ] 每个 Tool 是否有独立的 Client Contract / Server Contract / Shared Contract 文档?
- [ ] 是否在 Agent 侧实现了 pre-call validator?对
id_card、phone等关键字段做格式校验? - [ ] 是否在 Tool SDK 层统一处理所有异常,并映射为预定义 error code?
- [ ] Model Prompt 中是否明确声明了该 Tool 的错误响应规范及重试策略?
6.3 Tool 层检查(SLA 是否可量化?)
- [ ] Tool 是否独立部署?是否通过 gRPC/HTTP 调用,而非 import?
- [ ] 是否定义了 P95 延迟、错误率、可用性三项 SLA?是否有 Prometheus 监控?
- [ ] 是否配置了熔断器(circuit breaker)?连续失败多少次后自动熔断?
- [ ] 是否有降级方案?降级后是否仍能返回有意义的结果(如缓存、默认值、人工入口)?
6.4 Model 层检查(上下文是否受控?)
- [ ] 是否启用动态上下文压缩?压缩后 token 数是否 ≤ 原始的 20%?
- [ ] 是否为每个 Tool 打上元标签(latency_sensitive, data_freshness_critical...)?Prompt 是否要求模型按标签路由?
- [ ] 是否强制模型输出
confidence_score和uncertainty_reason?是否据此触发 human-in-the-loop? - [ ] 是否有上下文长度告警?当输入 token > 80% 模型上限时,是否自动触发压缩或告警?
6.5 全局检查(系统是否可运维?)
- [ ] 是否有全链路 trace ID?能否从用户 query 开始,追踪到每一次 tool call、model invoke、error log?
- [ ] 是否有会话级健康度评分?(基于 step_count, error_rate, latency_percentile 综合计算)
- [ ] 是否有自动化巡检脚本?每天凌晨自动调用 100 个典型 query,验证端到端成功率?
- [ ] 是否有故障注入演练?每月一次,随机 kill 一个 Tool 实例,验证降级是否生效?
这份清单里,没有一行是关于“选哪个大模型”或“用 LangChain 还是 LlamaIndex”。因为那些是选型问题,而可靠性是工程问题。我见过太多团队,模型选得天花乱坠,却在max_steps没设、retry_after没读、confidence_score没用这些基础项上栽跟头。真正的“可靠系统”,就藏在这些枯燥的勾选框里。
最后分享一个心得:不要追求 100% 可靠,要追求 100% 可解释的不可靠。当 Agent 出错时,你能立刻说出“它卡在 TOOL_EXECUTING 状态,因为风控 Tool 返回了 backend_down,且 retry_after=30,所以正在等待”,这就比“它坏了”强一万倍。后者需要 2 小时排查,前者 20 秒就能恢复。这才是工程师该有的掌控感。