Agent结构化输出问答器实战:从Schema设计到稳定性调优

📅 发布时间:2026/10/8 16:40:33
Agent结构化输出问答器实战:从Schema设计到稳定性调优
在折腾 Agent 的路上我一直被一个问题卡着模型对话能力再强返回的答案却始终是一段话而不是一份数据。尤其当我想把 Agent 接进后台系统、自动生成工单、回填数据库、驱动流程流转时一段漂亮的自然语言反而成了最大的麻烦——下游代码根本不知道该怎么消费它。这个痛逼着我去做了一个专门围绕结构化输出的问答器也就是标题里写的 Agent 实践第 4 期。简单说结构化输出问答器要解决的就是一件事让 Agent 在回答问题时不光是说人话还能按约定好的格式吐出 JSON 数据保证每个字段、类型、嵌套关系都稳定可控。这样前端可以直接绑定渲染后端可以直接持久化流程引擎可以直接拿字段做路由。这篇内容我尽量把从架构选型、状态机设计、稳定性调优到成本评测的完整链路讲清楚适合被 LLM 输出折腾过、准备把 Agent 推向生产环境的开发者参考。1. 先聊聊为什么问答器不能只回答得好1.1 一次让我崩溃的对接经历去年我做一个内部运维问答机器人最初的形态很朴素用户问帮我查一下生产环境 Nginx 的错误日志模型返回一段话里面有日志原文、错误码、出现频率。看起来信息挺全但当我试图把这套回答接进工单系统时噩梦开始了。我需要日志摘要、错误码、服务器 IP、时间范围、严重级别这五个字段。模型经常给我变着花样表达有时是错误码为 502有时是502 Bad Gatewayupstream 连接失败有时干脆写成错误码502Bad Gateway。更离谱的是有一次它把所有信息混在一个段落里连换行符都成了分隔符。我的解析正则写了一版又一版好不容易稳定一周换了个模型版本就全废。这段经历让我彻底明白自然语言是给人类看的容器不是给程序看的协议。想让 Agent 真正在业务系统里落地就必须在模型和代码之间建立一种可验证的契约。结构化输出问答器就是带着这种契约思维去重新设计对话系统。1.2 结构化输出的本质是契约而不是格式很多朋友一听结构化输出以为就是让模型输出 JSON。这个大方向没问题但理解得太浅会踩大坑。我现在的理解是结构化输出至少包含三层含义第一Schema 约束不仅要求 JSON 格式还要求字段名、字段类型、必填项、枚举值都符合约定。比如 severity 必须是 low / medium / high / critical 其中之一不能随便给个严重。第二可校验性输出结果可以被程序自动校验不合格就重试或降级而不是靠人眼检查。第三语义稳定性同一个业务含义在不同轮次、不同问法下最终落进同一个字段结构。这次回答里是server_ip下次也必须是server_ip不能突然变成host或ip_address。有了这三层Agent 才能从聊天窗口进化成业务接口。这也是我把这个项目命名为问答器而不是聊天机器人的原因它更像一个数据服务接口只不过入口是自然语言。1.3 问答场景比普通对话更依赖结构化普通闲聊型 Agent 不需要结构化输出模型随便发挥反而更自然。但问答器不一样它天生要面对问→查→答这种信息检索闭环用户问一个复杂问题里面藏着多个意图和参数Agent 需要把问题拆解成结构化查询条件查询数据后把结果命中原字段再返回。如果中间任何一环靠自然语言意会后续的数据查询就无法编写。比如我问帮我查一下昨天订单量和退款量按小时对比理想的情况是 Agent 内部直接生成类似{metrics: [order_count, refund_count], time_granularity: hour, date: 2025-02-10}这样的查询 DSL然后交给真正的查询引擎执行。如果模型回复好的昨天订单量是……那一切都得靠人工二次处理根本没有自动化可言。这本质上涉及了 Agent 架构中很核心的一个设计问题模型负责理解意图代码负责执行动作中间用结构化数据连接。这不是会不会调 API的问题而是对 Agent 边界的理解问题。2. 技术路线选型为什么我放弃了纯 Prompt 约束2.1 纯提示词约束在真实场景里的脆弱性结构化输出最朴素的做法是在 system prompt 里写请始终以 JSON 格式输出字段包括 xxx。我一开始也这么干效果在简单 demo 里还行一旦问题变复杂就暴露出一堆毛病模型在长上下文对话中容易忘记输出规范尤其是用户插了几轮话、相关性减弱之后回答里夹杂解释性文字以下是您要的结果这种废话经常把 JSON 围得严严实实字段名偶尔会变形比如把order_total写成total_order嵌套数据结构一深括号和引号就出问题直接导致 JSON.parse 失败。这些问题不是模型笨而是纯文本约束缺少硬性执行机制。提示词本质上是希望不是命令尤其当输出 token 受限或模型注意力分散时各种意外就来了。2.2 三种主流方案的对比与我的选择我在项目里把市面上常见的方案理了一遍简单对比方案原理优点缺点Prompt 后处理解析在提示词要求格式代码再尝试解析实现简单、通用性强稳定性差解析失败率高需要大量兜底逻辑模型平台自带 JSON Mode平台侧强制模型输出合法 JSON格式合法性有保障只能保证是 JSON不能保证符合具体 schema字段语义仍可能漂移工具调用 / 函数调用的参数绑定把输出结构定义为函数签名模型从预定义参数里选择字段稳定、类型可靠、天然带枚举校验兼容多轮上下文学习成本略高需要平台支持复杂嵌套定义相对繁琐顺带解释一下很多人纠结的harness 和 agent 区别。我自己的理解是harness 是容器和脚手架负责管上下文窗口、工具调度、循环控制、错误恢复这些执行层面的东西agent 是策略和大脑负责决定下一步调哪个工具、怎么合理解读结果。结构化输出问答器里harness 负责让对话循环稳定运转agent 负责把用户的含糊表达收敛成明确的结构化参数两者各司其职。我在这个项目里选择了工具调用 自定义输出 schema路线相当于把 harness 的调度能力和 agent 的决策能力都用上了。2.3 我最终采用的整体架构整个问答器的架构可以用一张图描述虽然我不太喜欢画复杂图但这里简单文字梳理一下入口层接收用户文本、会话 ID、可选的业务上下文意图解析模块使用模型的工具调用能力把用户输入转化为结构化意图对象状态管理模块维护每轮对话的状态记录包括已收集的参数、待补充项、历史问答摘要查询执行模块把结构化参数转化为数据库查询或 API 请求答案渲染模块把查询结果再次交给模型用预定义 schema 输出最终答案。这套架构的关键在于所有模块之间的数据交换都必须走结构化对象模型只在两个边界出现入口理解和出口生成。中间的业务逻辑完全由代码掌控不把大模型当数据库或者计算器使。3. 从零搭建问答器核心骨架3.1 定义全局输出 Schema我习惯先用 Pydantic 或者 TypeScript 类型把所有业务对象定义清楚因为 schema 是一切的基础。这里用一个简化版的巡检问答器举例场景是用户问某台服务器的运行状态Agent 返回结构化巡检结果。from typing import List, Optional from enum import Enum from pydantic import BaseModel, Field class Severity(str, Enum): LOW low MEDIUM medium HIGH high CRITICAL critical class MetricItem(BaseModel): name: str value: float unit: str Field(..., description指标单位) severity: Severity class InspectionResult(BaseModel): host: str Field(..., description主机名或IP) status: str Field(..., descriptionoverall / degraded / down) metrics: List[MetricItem] Field(..., description关键指标列表) summary: str Field(..., description一句话总结) recommended_actions: Optional[List[str]] None这个 schema 看起来简单但它规定了三层契约顶层字段必须有哪些、嵌套指标如何组织、枚举值范围是什么。模型在生成时如果走了工具调用就必须在这个结构里选值试了很多次字段稳定性明显比纯文本约束高一个量级。3.2 状态机的设计与流转逻辑问答器不能假设用户第一句话就把所有参数给齐。真实对话里用户可能会说帮我查下那台服务器的状态但你不知道是哪台。所以必须有一个状态机来跟踪收集过程。我把状态流转设计为四态清空态EMPTY等待新请求或重置上下文收集态COLLECTING)已识别意图但某些必填参数缺失需要追问查询态QUERYING参数齐全正在执行数据查询渲染态RENDERING已拿到数据正在组织最终输出。举一条完整链路用户说查一下 web-01 的状态。意图解析模块生成{intent: inspect, host: web-01}状态机发现无缺失参数进入查询态查询引擎拿到host后执行监控数据拉取结果进入渲染态模型按InspectionResultschema 组织输出如果用户说查一下那个最近老报警的机器状态机进入收集态追问请提供具体主机名或 IP。这个流转逻辑完全由代码控制模型不决定下一步要做什么只负责把用户的话翻译成结构化数据。这就避免了 agent 常见的自由发挥导致流程失控的问题。3.3 工具调用与意图路由的绑定实现有了 schema 和状态机下一步是把意图解析做成一次工具调用。下面是一段非常典型的伪代码用的是通用型大模型接口tools [{ type: function, function: { name: parse_inspection_intent, description: 解析用户巡检意图提取查询参数, parameters: { type: object, properties: { intent: {type: string, enum: [inspect, history, help]}, host: {type: string, description: 主机名或IP不明时留空}, time_range: {type: string, description: 时间范围例如last_1h}, }, required: [intent] } } }] response model.call( messagesconversation_history, toolstools, tool_choice{type: function, function: {name: parse_inspection_intent}} )这段代码核心就两点强制走某个函数让模型必须填参数不要求全部参数齐全避免模型为了完成调用而瞎编。我还在 prompt 里明确告诉模型如果用户没有提供主机host 字段保持空字符串不要猜测。3.4 查询结果到最终答案的渲染闭环渲染阶段同样需要结构化约束。查询引擎拿到host web-01后返回一组监控指标这时我再调用模型生成最终答案但这次调用的工具签名就是InspectionResult本身。我没有让模型直接看原始 JSON而是先把数据整理成易读的文本再附上严格说明基于提供的数据生成巡检结果不得虚构指标未提供的字段留空。这个查询→摘要→结构化输出的闭环有一个隐藏好处数据在中间层经过了代码校验和清洗模型不会因为原始数据里藏着异常格式就跟着出错。我把所有可能破坏输出的脏数据在到达模型前就过滤掉了。4. 稳定性调优从偶尔出错到基本可靠4.1 输出 schema 变更要纳入版本管理第一版跑通后我遇到一个特别隐蔽的问题改了 schema 里的字段描述或枚举值线上模型行为会突然变化但我们完全没察觉。比如给recommended_actions加了一句最多返回 3 条模型就开始在渲染时额外输出建议列表导致某些旧逻辑解析失败。现在我把 schema 定义当作接口协议来管理每次修改都走版本记录并且用契约测试锁定关键行为固定几条历史对话输入断言输出 json 的字段集合和类型必须稳定。这是结构化输出项目里最容易忽略却最值得投入的一块。4.2 JSON 容错解析的三层修复就算有了工具调用约束模型依然可能在边缘场景输出不完美的 JSON尤其当上下文很长或结果集很大的时候。我总结了一套三层修复策略实测能把解析成功率从 88% 拉到 99.2%第一层规范化处理。把 markdown 的代码块围栏json 和剥掉把全角字符转半角把尾部的逗号、多余换行清理掉。第二层部分提取。如果整体解析失败尝试用正则或简单地扫描花括号配对把最外层完整对象切出来再解析。优先保留结构完整度最高的片段。第三层重试降级。前两层都失败时把错误信息和原始输出拼进重试请求让模型修复刚才生成的不合法 JSON。注意要给这次请求设定明确指令比如只输出修复后的 JSON不要任何解释。这套策略的实际效果我测下来很满意。但需要注意重试会增加 token 消耗和时延所以我会把它作为兜底而不是默认路径。4.3 流式输出里的JSON 竞态问题我用流式输出提升交互体验后遇到了一个全新的坑前端按流逐字处理时收到的其实是未完成的 JSON直接 parse 必然失败。这不是模型问题而是谁负责消费流的问题。我的解决方案是双通道设计流式通路用来给人类用户展示正在思考的过程同时把模型输出累积到缓冲区等完整输出结束后统一以 JSON 形式推送给业务侧。要是业务侧硬要边流边解析就得自己实现一个增量 JSON 解析器成本不低我不建议在初期做。4.4 token 预算和 schema 描述的取舍schema 字段描述写得越细模型越不容易跑偏但也意味着每次工具调用都会消耗更多 token。我后来做了个折中的决定必填字段和高风险枚举值写详细描述普通可选字段只保留类型约束。比如 host 字段描述必须写清楚必须是可访问的主机名否则留空因为它是查询的关键而 recommended_actions 这种非关键字段就不用写得太啰嗦。这个优化在单次调用里看不出太大差距但如果问答器一天被调用几万次累积省下的 token 费用是相当可观的。项目上线后我把相关数据导出来算过账光描述精简那部分就省了大约 12% 的输入 token。5. 多轮对话中的具身状态问答器能否真正可用全看这里5.1 多轮状态不能只靠模型记忆第一版问答器的多轮处理非常天真把整段会话历史全部塞进上下文让模型自己回忆用户说过什么。后来发现随着对话轮数变长模型会把上一轮的主机名安到下一轮的问题上。比如用户先问了 web-01再问那数据库呢它的意图解析本该是查数据库主机结果有时会输出{host: web-01-db}有时直接复用 web-01。教训就是轮次间的关键参数必须在代码里显式维护不能依赖模型隐式记忆。我把会话状态存储成独立的业务对象每次解析意图时把已有状态和用户新输入一起交给模型模型只负责增量更新不负责做全局回忆。这个改动让多轮问答的准确率明显提升模型需要负担的上下文也小了很多。5.2 字段的合并、覆盖与清空策略维护状态对象看似简单但细节很多。我制定了三条规则合并新输入里没有的字段沿用旧值新输入里有且非空的字段覆盖旧值这是默认动作冲突当用户新输入与旧值直接矛盾时旧值被清空重新进入收集态并回问用户您想查的是哪个;清空当意图切换时例如从 inspect 切到 history默认清空所有和旧意图绑定的字段避免残留数据污染新一轮查询。这三条规则在实际交互里非常有用。比如用户先说查 web-01然后说不我说的是 db-02模型解析出来 host 字段就是 db-02旧值被覆盖后续查询自然就准了。5.3 交互中被问懵的场景怎么处理另一个容易翻车的情况是用户反问模型。比如用户说你觉得 web-01 的问题严重吗如果不对这个问题做拦截意图解析模块会拼命想把它塞进inspect意图结果 host 缺失状态机又去追问对话体验很差。我后来加了无法明确意图的兜底分支当模型输出的函数调用里 intent 和关键参数都为空或者模型主动输出非结构化回复时默认进入答疑模式先回应用户的问题再尝试把话题拉回收集流程。这个兜底看似简单却能让问答器从死板的信息采集器变成有基础的对话助手。6. 上线前的最后一公里评测、成本与收敛6.1 怎么构建一套有效的 Agent 评测集热搜里一直有人提agent 评测集构建我确实花了很多力气在这一块。结构化输出问答器的评测不能只看答得对不对还要看格式稳不稳字段全不全流程走没走歪。我按照三个维度搭了评测集维度评测问题示例判定标准固定功能查一下 web-01 的 CPU 使用率意图识别正确字段完整查询执行正确最终输出 JSON schema 合法边界情况查一下那台机器主机模糊状态机正确进入追问态没有瞎猜参数交互韧性用户打断、改口、反问、补参数状态更新符合合并/覆盖/清空规则无残留每个维度都准备了三条左右的高频变体每次升级模型或改配置都用这套集跑一遍回归对比通过率。这个过程虽然枯燥但对于生产环境来说极其重要否则你无法判断这次改动是变好了还是变坏了。6.2 Token 成本与响应时延的实测数据上线前我做了一轮压测拿 500 条真实会话记录回放统计 token 消耗分布。结果是单轮问答平均消耗输入 token 约 1800输出 token 约 350意图解析阶段占大头的是 system prompt 加 schema 描述渲染阶段占大头的是数据和 summary。针对这两个大头我做了两个优化意图解析的 system prompt 精简到只保留必要规则把长文案例挪到外部参考里只在需要时注入渲染阶段的查询结果先做代码侧摘要不必把全量原始日志塞给模型。优化后单轮平均 token 消耗下降了约 18%响应时延从 2.1 秒降到 1.4 秒左右。这个数据虽然不是极端压缩后的效果但已经足够支撑业务侧的成本预期。6.3 缓存策略别让每个问题都重新算一遍问答器有一些高频查询是重复的比如web-01 现在的状态用户可能一分钟问三次。对这类查询我在查询执行层加了一层短时缓存TTL 设为 30 秒。命中缓存时直接跳过查询和渲染只做一次简单格式化。这层缓存在架构上很简单但收益非常直观大约 25% 的重复问题完全不需要模型参与省下的 token 和数据库压力都很明显。需要小心的是缓存 key 的设计必须包含所有影响查询结果的关键参数不然容易串数据。6.4 多 Agent 与 Skill 化进阶扩展方向做完这个问答器我又想到两个很自然的扩展方向。第一个是多 Agent 分工现在单个问答器负责全部意图再过一阵业务变复杂可以把巡检问答历史趋势分析故障处置建议拆成三个 Agent共享同一套状态协议这样单个 Agent 的意图空间更小稳定性会更好。第二个是Skill 化把问答器整理成一个可复用的 Skill其他 Agent 需要巡检能力时直接挂载可以少做很多重复开发。现在很多 Agent 框架都支持这种 skill 机制把一个结构化输出流程封装成独立能力比把所有逻辑揉进一个 Agent 里要清晰得多。最后说点实在的。结构化输出问答器这个项目让我最深刻的认识是Agent 能不能在生产环境用起来往往不是模型聪明不聪明的问题而是你有没有给它一套稳定、可验证、能兜底的输出协议。我踩过的坑基本都是围绕这个核心展开的。如果你也在做一个类似的东西我的建议是先从极小的 schema 开始把一条链路走通再逐步加复杂约束一上来就想着覆盖全部业务场景只会让状态机和错误处理膨胀到难以维护。把基础打扎实结构化输出带来的收益会远超你的预期。