Tool Calling 与工程化

大模型 Agent 项目中的四类 API 故障:从结构化输出、Schema 校验到异步轮询与网关漂移

从真实失败路径总结模型 API、结构化输出、轮询和网关配置的诊断方法。

LLM APISchema轮询

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 没有返回证据。但执行轨迹说明,失败发生在工具执行之前。

如何缩小范围

排查顺序是:

  1. 普通模型请求成功,说明 API Key、Base URL 和基础网络并非完全不可用。
  2. 失败发生在结构化调用阶段,说明问题与诊断证据是否充分无关。
  3. Single 与 Multi 使用相同供应商,但调用结构化输出的路径不同。
  4. 对比模型配置和 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 capability
  • f2c1ffb fix: honor specialist structured output method
  • ae615a8 fix: generate root cause decisions with structured output
  • a886f04 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 adaptable
  • 9944433 fix: classify validator failures safely
  • b7539df fix: classify validator parse failures safely
  • c74703f fix: preserve validator retry failure history
  • 6d9e175 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_requiredexecutionPermitted=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 中包含非空 idstatus
  • 缺失、空值、嵌套位置错误或未知状态均立即抛出 IndexProtocolError

哪些错误可以重试

轮询器把状态和错误分成了不同类别:

情况行为
pendingrunning等待后继续查询
succeeded返回已确认完成的任务
failedcancelled终止并报告失败原因
未知状态、缺失字段、非法 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 polling
  • 6d6ce64 refactor: reuse knowledge index poller
  • 244e9e0 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 nginx
  • f3dbff7 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、原始日志、供应商异常正文或凭据。稳定的错误码通常比一段无法公开的异常堆栈更适合长期统计。

容易犯的几个错误

  1. 把 HTTP 200 当成调用成功。 对 Agent 来说,只有通过业务 Schema 和证据校验的响应才可使用。
  2. 对所有失败统一重试。 能力不兼容和协议错误需要修代码,重复请求只会增加时间与费用。
  3. 认为 OpenAI-compatible 等于能力完全一致。 接口兼容不代表 Structured Output、工具调用和 JSON Schema 行为一致。
  4. 让组件 timeout 脱离全局 deadline。 局部重试可能耗尽整个 Workflow 的时间预算。
  5. 让单元测试继承开发环境地址。 测试应该显式注入自己的 Base URL。
  6. 在 Validator 失败时放宽恢复权限。 外部核验组件不可用时,安全边界应该保持或收紧。

总结

这四次问题看似分别属于 LLM、RAG、Nginx 和前端测试,实际都指向同一件事:API 集成不是把请求发出去,而是维护一组端到端契约。

  • 模型能力契约决定应该使用哪种结构化输出方式;
  • 数据契约决定成功响应是否能被程序安全消费;
  • 时间和状态契约决定异步任务与重试何时继续、何时停止;
  • 部署契约决定客户端、网关、后端和测试分别应该访问哪里。

当这些契约都变得显式、可测试、可审计之后,Agent 才能从“偶尔跑通的模型调用”逐步变成“失败时也知道为什么、能够安全退出的工程系统”。

陈涛 · Agent Application Developer

杭州 · 2026