Tool Calling 与工程化
大模型 Agent 项目中的四类 API 故障:从结构化输出、Schema 校验到异步轮询与网关漂移
从真实失败路径总结模型 API、结构化输出、轮询和网关配置的诊断方法。
API Key 没错,普通请求也能成功,为什么 Agent 还是跑不完?
在开发一个基于 LangGraph 的 AIOps Agent 时,我先后遇到了四种看起来很像、实际处于不同层次的 API 故障:模型接口返回 4xx、模型响应成功但无法通过 Schema、异步索引任务无法稳定完成,以及前端测试仍然访问旧的后端地址。
它们让我逐渐意识到:“接口可达”只是 API 集成的最低要求。 一个生产级 Agent 还要同时管理模型能力、响应数据、异步状态、时间预算和部署拓扑等契约。
本文复盘的是项目开发过程中真实发生的集成问题,不是 Benchmark 中人为注入的 Nginx 504、数据库死锁或 Redis 连接耗尽场景。文中数据只用于描述对应的单次验收,不代表生产 SLA。
先看结论:四个问题其实对应四种契约
| 真实问题 | 表面现象 | 实际破坏的契约 |
|---|---|---|
| DashScope 结构化输出不兼容 | 普通调用成功,Decision/Specialist 返回 4xx | 模型能力契约 |
| Validator 响应无法通过 Schema,纠正重试又超时 | HTTP 请求有响应,业务仍然失败 | 数据契约与时间契约 |
| 知识索引轮询不可靠 | 任务已创建,脚本却无法确认索引完成 | 异步状态契约 |
| Nginx 网关迁移后测试仍依赖旧地址 | 实现走 8080,测试断言仍是 8000 | 部署拓扑与配置契约 |
这四类问题不能使用同一种重试逻辑解决。能力不兼容需要选择正确调用方式;非法响应需要拒绝并记录;临时网络故障可以有界重试;配置漂移则需要消除硬编码。
项目中的调用链
问题出现时,系统的主要链路可以简化为:
flowchart LR
A[Planner] --> B[工具调用与证据收集]
B --> C[Root Cause Decision]
C --> D[LLM Validator]
D --> E[Recovery Planner]
E --> F[Policy Gate]
B -.检索.-> K[知识索引与 RAG]
U[Vue 前端] --> N[Nginx :8080]
N --> S[FastAPI :8000]
模型 API 不只负责生成聊天文本。Planner、Decision、Specialist 和 Validator 都可能要求结构化输出;RAG 导入还包含创建任务和查询状态两个接口;前端的普通请求与 SSE 流式请求则统一经过 Nginx。
因此,一次“Agent 失败”可能来自链路中的任何一层。
案例一:普通模型调用成功,Structured Output 却返回 4xx
发生了什么
最初的现象很有迷惑性:使用同一个 API Key 的普通 Qwen 调用可以成功,但进入 Root Cause Decision 或 Multi-Agent Specialist 的结构化调用后,系统记录了:
provider_4xx / model_call_failed
一次 Single/Multi 对比中,Single Agent 能够继续运行,Multi Agent 的 Runtime Specialist 和 Log Specialist 却都在生成 Local Plan 时失败,甚至还没有开始调用诊断工具。
如果只看最终结果,很容易误判为 Multi-Agent 逻辑有问题,或者认为 CLS、RAG 没有返回证据。但执行轨迹说明,失败发生在工具执行之前。
如何缩小范围
排查顺序是:
- 普通模型请求成功,说明 API Key、Base URL 和基础网络并非完全不可用。
- 失败发生在结构化调用阶段,说明问题与诊断证据是否充分无关。
- Single 与 Multi 使用相同供应商,但调用结构化输出的路径不同。
- 对比模型配置和 Specialist 实现,发现代码默认假设模型支持同一种 Structured Output 方法。
问题最终落在模型能力契约上:当前 Qwen 模型需要通过 LangChain 的 json_mode 获得结构化结果,而部分路径仍按另一种方法调用;Specialist 又没有完整遵循已经配置的 Structured Output 方法。
修复方式
修复没有继续堆叠 Prompt,而是把“模型支持什么”变成显式配置:
{
"model": "qwen3.7-plus",
"structuredOutputMethod": "json_mode"
}
随后做了三件事:
- 为不同模型建立 capability profile,而不是假设所有 OpenAI-compatible 接口能力相同;
- 让 Decision 和 Specialist 都遵循
structuredOutputMethod; - 在
json_mode提示中附带安全的输出 Schema,并对返回结果继续做严格解析。
相关实现提交包括:
1d5ac82 fix: configure structured output by model capabilityf2c1ffb fix: honor specialist structured output methodae615a8 fix: generate root cause decisions with structured outputa886f04 fix: include specialist schema in json mode prompts
如何验证没有“对着答案修”
修复后先运行只包含合成公开事实的 Structured Decision readiness。它不读取真实日志、Ground Truth 或隐藏答案,并在 24.1 秒内通过。
后续一次真实 forced-Multi canary 达到 100/100 VALID_PASS,Root Cause Top-1 正确、Evidence Recall 为 100%,包含四个独立证据来源组且没有重复 Evidence。这个结果只证明该次固定环境验收通过,不代表 Multi-Agent 在所有场景中都优于 Single Agent。
本节结论:OpenAI-compatible 通常只表示接口形状接近,不表示模型能力完全一致。模型名、Structured Output 方法和解析合同应该一起配置。
案例二:Validator 收到了响应,系统为什么仍然判定失败
HTTP 成功不等于业务成功
第二个问题发生在 Decision Validator。它负责核验根因结论是否被当前任务的公开证据支持。
一次真实 canary 中,Validator 第一次结构化调用在 23,973 ms 后返回。供应商请求本身成功,但结果无法通过 Pydantic Schema。系统随后加入格式纠正指令并重试,第二次调用又受到整个 Agent 运行剩余硬截止限制,在 28,293 ms 后以 timeout/model_invoke 结束。
最终审计记录为:
validationOrigin = llm_failed
semanticValidationAttempts = 2
validationErrorCode = timeout
executionPermitted = false
这不是“Validator 判断根因错误”,而是 Validator 没有生成程序可以安全消费的结果。
严格 Schema 为什么仍然必要
Validator 的语义任务可以由 LLM 完成,但下游 Policy Gate 不能依靠一段自由文本决定是否允许恢复。因此系统需要一个明确的公开输出合同,例如:
{
"status": "valid",
"reasonCodes": [],
"unsupportedClaims": [],
"missingEvidenceIds": [],
"summary": "The candidate is grounded in the supplied evidence."
}
示例只展示结构,不携带真实 Evidence、日志或 Ground Truth。字段缺失、类型错误、非法枚举、额外字段和引用其他任务的 Evidence ID 都不能静默接受。
问题不在于 Schema 太严格,而在于系统此前缺少两项配套能力:可定位的解析错误和感知全局时间预算的纠正重试。
修复方式
第一步是把笼统的 model_call_failed 拆成安全的错误分类,例如:
- 供应商调用错误;
- 调用超时;
- 非法 JSON 或错误 envelope;
- 缺失字段、错误枚举或错误容器类型;
- 引用了不属于当前任务的 Evidence;
- 格式纠正重试耗尽。
审计记录只保存允许列表中的模型名、错误码和阶段,不保存 API Key、完整 Prompt、原始响应、异常正文或原始 CLS 日志。
第二步是让纠正重试服从整个 Workflow 的 hard deadline:
remaining = hard_deadline - monotonic()
required = validator_role_timeout + scheduling_margin
if remaining >= required:
retry_with_format_correction()
else:
record("retry_skipped_insufficient_deadline")
当前合同要求至少保留 60 秒 Validator role timeout,再加 5 秒调度余量;不足时不再请求供应商,也不额外消耗模型预算。
第三步是加入确定性安全降级。如果 LLM Validator 不可用,但候选通过全部公开的证据绑定检查,系统可以保留诊断结论,不过只能进入:
deterministic_grounded_fallback
recoveryMode = manual_review
executionPermitted = false
外部模型故障不能成为自动执行恢复操作的理由。
相关提交包括:
d5f6933 refactor: make validator invocation adaptable9944433 fix: classify validator failures safelyb7539df fix: classify validator parse failures safelyc74703f fix: preserve validator retry failure history6d9e175 fix: make validator correction deadline aware
修复后的真实验收
后续真实 Run v4-validator-contract-canary-20260821-1787319745759 中,Validator 首次结构化调用在 28,560 ms 内返回 llm_semantic/valid,没有发生解析、超时或重试错误。该 Run 得到 100/100 VALID_PASS。
即使语义核验成功,Policy Gate 仍返回 external_policy_required 和 executionPermitted=false。这说明 Validator 只核验结论,不拥有恢复授权。
本节结论:LLM 返回文本只是传输成功;通过业务 Schema、证据边界和安全策略之后,才算一次有效的模型调用。
案例三:异步知识索引为什么不能只写一个 while 循环
创建任务与查询任务不是同一个响应结构
向知识库导入文档时,索引不是在上传请求中同步完成的。客户端先创建索引任务,再根据任务 ID 轮询状态。
这里曾经混淆了两个相近但不同的响应合同:
POST .../index-tasks
└── data.task.id
data.task.status
GET .../index-tasks/{task_id}
└── data.id
data.status
如果查询接口仍按创建接口的 data.task.status 读取,就会得到不存在或空的轮询字段。继续把它当成“任务还没完成”会掩盖协议错误,并可能一直等待到超时。
因此,最终实现没有把空 status 当作临时状态继续轮询,而是明确规定:
- 创建响应必须符合
data.task; - 查询响应必须直接在
data中包含非空id和status; - 缺失、空值、嵌套位置错误或未知状态均立即抛出
IndexProtocolError。
哪些错误可以重试
轮询器把状态和错误分成了不同类别:
| 情况 | 行为 |
|---|---|
pending、running | 等待后继续查询 |
succeeded | 返回已确认完成的任务 |
failed、cancelled | 终止并报告失败原因 |
| 未知状态、缺失字段、非法 JSON | 立即报告协议错误 |
| 网络错误、读取超时 | 在次数和总 deadline 内重试 |
| 总 deadline 到期 | 抛出超时,并保留最后已知状态 |
简化后的状态机如下:
stateDiagram-v2
[*] --> Query
Query --> Query: pending / running
Query --> Success: succeeded
Query --> TerminalFailure: failed / cancelled
Query --> ProtocolError: malformed / unknown status
Query --> Query: bounded transport retry
Query --> Timeout: deadline exceeded
这里的关键是:只有传输层的临时错误可以重试,成功响应中的非法业务结构不能被重试掩盖。
共享轮询器,而不是复制脚本
项目最终抽出了 knowledge_index_client.py,由它统一负责:
- 创建任务和查询任务的类型化解析;
pending → running → succeeded状态流转;- 有界传输重试;
- monotonic deadline;
- 终止失败、协议错误与超时分类。
原有 SOP 导入脚本和后续批量知识导入脚本复用同一个轮询器。测试通过 httpx.MockTransport 和可注入的单调时钟覆盖成功、临时超时后成功、终止失败、deadline 到期、字段缺失和未知状态,不需要在普通 CI 中访问真实模型或数据库。
相关提交包括:
05b5842 fix: harden document index polling6d6ce64 refactor: reuse knowledge index poller244e9e0 feat: add reviewed knowledge batch import
本节结论:轮询不是“请求直到成功”,而是一个具有合法状态、错误类别和总时间边界的小型协议客户端。
案例四:Nginx 接入后,为什么前端测试突然失败
端口没有错,错的是测试继承了环境配置
引入 Nginx 后,本地调用拓扑发生了变化:
Vue/Vite -> http://127.0.0.1:8080 -> Nginx
|
v
http://host.docker.internal:8000 -> FastAPI
项目模板中的 frontend.apiBaseUrl 和演示脚本入口从 8000 切换到了 8080。FastAPI 仍然监听 8000,只是 8000 变成后端直连调试入口,普通前端流量应通过 Nginx。
问题出现在前端单元测试:Chat Session 请求和 SSE 流式请求默认读取了项目 API Base URL,但测试断言仍然硬编码:
http://127.0.0.1:8000/chat/...
当默认配置迁移到 8080 后,测试行为随环境改变,原有断言随即失效。
为什么不应该把实现改回 8000
如果为了让测试通过而把前端重新指向 FastAPI,就会绕过 Nginx 的统一入口、限流和代理配置,破坏原有架构目标。
真正的问题是测试没有声明自己的依赖。修复方式是给测试客户端注入独立地址:
const TEST_API_BASE_URL = "http://protected-data.test";
const client = createProtectedDataClient({
baseUrl: TEST_API_BASE_URL,
storage,
fetchImpl,
});
随后所有普通请求和 SSE 断言都基于 TEST_API_BASE_URL,不再依赖开发机当前使用 8000、8080,还是其他端口。
对应提交是:
d9c3d9d feat: route local api traffic through nginxf3dbff7 test: isolate protected data API base URL
需要注意:config/project.test.json 仍可直连测试后端,这是测试环境自己的显式选择;问题不在于测试必须经过 Nginx,而在于单元测试不能无意继承开发环境地址。
本节结论:配置迁移后测试失败,不一定说明新架构错误,也可能说明测试把环境变量误当成了固定业务合同。
从四次故障中提炼一套 API 排查顺序
面对“Agent 调用失败”,我现在会按下面的顺序定位,而不是立即增加重试:
1. 先判断失败发生在哪一层
连接失败?
-> DNS、TLS、网络、鉴权、供应商状态
HTTP 非 2xx?
-> 请求参数、模型能力、限流、供应商错误
HTTP 2xx 但解析失败?
-> JSON、Envelope、Schema、枚举、字段类型
解析成功但流程失败?
-> 证据边界、状态机、全局 deadline、安全策略
只有特定环境失败?
-> Base URL、代理、端口、测试夹具和配置漂移
2. 重试前先判断错误是否可能自行恢复
适合重试的通常是短暂网络错误、读取超时和明确可重试的供应商状态。下面这些问题重复请求通常不会自行修复:
- 模型不支持当前 Structured Output 方法;
- 成功响应违反 Schema;
- 查询了错误的 JSON 路径;
- Base URL 指向了错误拓扑;
- 剩余全局时间不足以完成下一次调用。
3. 同时保存局部超时和全局 deadline
每个模型或 HTTP 请求可以有自己的 timeout,但它必须服从整个 Agent Run 的 hard deadline。否则局部组件不断重试,会让最后的 Validator、报告或清理步骤没有执行时间。
4. 把失败分类保存下来,但不要泄漏原始敏感数据
一个可审计的错误记录至少应该回答:
- 哪个角色或组件失败;
- 失败发生在调用、解析还是校验阶段;
- 是否发生过重试;
- 最终采用了什么降级路径;
- 是否允许执行有副作用的操作。
它不一定需要保存完整 Prompt、原始日志、供应商异常正文或凭据。稳定的错误码通常比一段无法公开的异常堆栈更适合长期统计。
容易犯的几个错误
- 把 HTTP 200 当成调用成功。 对 Agent 来说,只有通过业务 Schema 和证据校验的响应才可使用。
- 对所有失败统一重试。 能力不兼容和协议错误需要修代码,重复请求只会增加时间与费用。
- 认为 OpenAI-compatible 等于能力完全一致。 接口兼容不代表 Structured Output、工具调用和 JSON Schema 行为一致。
- 让组件 timeout 脱离全局 deadline。 局部重试可能耗尽整个 Workflow 的时间预算。
- 让单元测试继承开发环境地址。 测试应该显式注入自己的 Base URL。
- 在 Validator 失败时放宽恢复权限。 外部核验组件不可用时,安全边界应该保持或收紧。
总结
这四次问题看似分别属于 LLM、RAG、Nginx 和前端测试,实际都指向同一件事:API 集成不是把请求发出去,而是维护一组端到端契约。
- 模型能力契约决定应该使用哪种结构化输出方式;
- 数据契约决定成功响应是否能被程序安全消费;
- 时间和状态契约决定异步任务与重试何时继续、何时停止;
- 部署契约决定客户端、网关、后端和测试分别应该访问哪里。
当这些契约都变得显式、可测试、可审计之后,Agent 才能从“偶尔跑通的模型调用”逐步变成“失败时也知道为什么、能够安全退出的工程系统”。