
做问答器这件事我一开始的姿势特别朴素把模型返回的文本直接拼进接口前端拿到什么渲染什么。结果上线一周产品经理拿着截图来找我同一道题模型有时用**加粗有时用1、2、3列点还有一次把答案写在表格里。前端同学为了兼容这些格式写了一百多行正则还是不断有新格式冒出来。那次之后我意识到一件事大模型输出的是字符串但业务系统要的是对象。所以这个 Agent 实践系列的第 4 篇我决定专门聊透「结构化输出问答器」——核心就一句话用一份 JSON Schema把模型的每次回答钉死成结构化对象让前端、接口、评测脚本都按同一份约定读取而不是靠猜。这篇文章适合两类人一类是刚接触 Agent 开发想让 LLM 输出真正进到业务逻辑里的同学另一类是被自由文本输出折磨过、想找一套稳妥接法的开发者。我会把选型逻辑、数据模型、提示词写法、解析重试链路、实测翻车现场全部拆开讲代码可以直接抄。1. 为什么问答器必须先定 Schema从两次真实对接故障说起1.1 故障一字符串地狱前端被玩成正则工程师我先复盘第一次对接事故。当时做一个百科问答器用户问什么模型答什么我把message.content原样丢给前端。听起来没问题对吧但自然语言输出的格式自由远超想象。同一个问题解释一下 TCP 三次握手模型这次给的是TCP三次握手是建立可靠连接的基础流程 第一客户端发送SYN 第二服务端回复SYNACK 第三客户端再发送ACK。下次可能是**TCP三次握手** 1. SYN 2. SYNACK 3. ACK 附为什么不是两次再下次可能是表格甚至是 Markdown 代码块里的表格。前端要渲染答案 关联问题 置信度标签最开始的实现是正则匹配**...**、第一、1.、-结果就是补丁摞补丁。我印象最深的是某个模型在答案里用了① ② ③前端正则没覆盖整个卡片变成纯文本输出产品验收时直接打回。这个故障的本质不是前端不够努力而是输出层没有契约。文本天生没有固定结构任何依赖结构的下游都会被不可控格式拖垮。1.2 故障二证据溯源无法自动化人工盯了一天源头第二个故障发生在接入知识库问答之后。业务方要求每条答案必须带证据来源——就是模型依据了哪些文档片段得出这个结论。模型倒是配合每次都在答案后面写来源某某文档第几页参考帮助中心-退款流程-第2段……但格式完全不统一有的写在开头有的写在结尾有的用括号括起来有的干脆只写根据相关资料。我们想做一个自动提取来源并关联到知识库页面的功能结果发现没法从自由文本里稳定抽出来。更麻烦的是评测环节。我们拿一百道题跑测试集需要自动判断答案是否正确、来源是否真实存在。因为没有结构化字段这些判断全都得靠人去读。一百条人工标注下来半天就没了还容易出错。1.3 一份 Schema 带来的改变让模型填表而不是写作文两次故障让我下定决心问答器的输出必须是一个对象而不是一段文本。我先定义了下面这个数据模型from pydantic import BaseModel, Field from typing import Literal class QAAnswer(BaseModel): answer: str Field( description对用户问题的中文回答200字以内 ) evidence: list[str] Field( default_factorylist, max_length3, description支撑答案的关键依据摘要最多3条 ) confidence: Literal[high, medium, low] Field( description模型对答案的置信度 ) needs_followup: bool Field( defaultFalse, description是否需要用户补充信息才能给出准确答案 ) related_questions: list[str] Field( default_factorylist, max_length3, description用户可能继续追问的问题最多3个 )这 5 个字段不是拍脑袋定的每一个都对应一个踩坑后的真实需求answer是主干内容前端直接渲染不解析、不处理。evidence替代来源自由文本后端拿这个字段去关联知识库。confidence用来决定是否在界面上显示该回答为推测性内容的提示。needs_followup解决另一类问题——用户问题本身模糊比如这个多少钱模型硬答反而容易错这个字段让系统可以反过来引导用户补充信息。related_questions是问答器的留存神器用户答完可以一键点追问。定义好这份契约之后我的所有工作都围绕如何让模型稳定产出符合这个 Schema 的 JSON展开。这就是结构化输出问答器的核心思路也是后续所有方案选型的前提。2. 结构化输出的四条路线与我的选型逻辑2.1 JSON Mode只保语法不保结构最早上手的是各家 API 提供的 JSON Mode。典型写法是 OpenAI 的response_format{type: json_object}本地模型则常见format: json。JSON Mode 做的事情很简单保证模型返回的是一个合法 JSON 字符串。它不保证你需要的字段都在不保证字段类型正确更不保证不出现多余字段。如果你只写一句请输出JSON模型可能返回{result: ...}也可能返回{answer: ..., confidence: very high}字段名和枚举值全靠模型当天心情。所以我的判断是JSON Mode 适合对结构要求不高的场景比如做一个临时的数据抽取脚本。但对要长期维护的问答器来说它只是底线不是方案。2.2 Function / Tool Calling让模型学会正确的填空Tool Calling 是比 JSON Mode 靠谱得多的路线。它的思路很巧妙把输出结构声明成一个工具函数的参数让模型通过调用函数的方式来提交结构化数据。举个例子我把上面的QAAnswer声明成一个工具函数submit_qa_answer并告诉模型你只有一个任务就是调用这个函数提交回答。模型返回的不是普通文本而是一个tool_calls数组其中function.arguments就是符合参数 Schema 的 JSON 字符串。这条路线的好处在于参数 Schema 由开发者定义字段名、类型、枚举、必填项都在服务端控死。主流模型基本都支持 tool calling兼容性远好于 strict JSON Schema 模式。后续扩展成本低你想让 Agent 去查数据库、调用搜索接口本质上都是再声明几个工具函数同一个体系直接复用。2.3 本地开源模型的两难strict 支持有限提示词兜底不能省用开源模型跑问答器时情况会更复杂。像 Qwen、DeepSeek、GLM 这类模型有些版本对response_format的 strict 模式支持不完整甚至同一系列不同版本行为都不同。我实际测试过同一个提示词模型 A 能稳定输出 JSON模型 B 会偶尔在 JSON 外面包一层 Markdown 代码块。所以本地模型下我的策略是组合拳在 System Prompt 中给出完整的 JSON 示例越具体越好。参数里开启 JSON Mode如果有。解析层用 Pydantic 校验失败就带上错误信息重试一次。如果模型本身支持 tool calling优先走 tool calling行为会稳很多。2.4 Schema 反哺 Prompt让模型照猫画虎还有一个很容易被忽略的细节QAAnswer.model_json_schema()可以直接生成 JSON Schema把这份 Schema 原样放进 System Prompt 里比写十句话描述你要输出什么都管用。print(QAAnswer.model_json_schema())输出大致是{ properties: { answer: {title: Answer, type: string}, confidence: { enum: [high, medium, low], title: Confidence, type: string }, evidence: { items: {type: string}, title: Evidence, type: array }, needs_followup: {title: Needs Followup, type: boolean}, related_questions: { items: {type: string}, title: Related Questions, type: array } }, required: [answer, confidence], title: QAAnswer, type: object }模型对 JSON Schema 的跟随能力通常比自然语言描述好得多。尤其是枚举字段enum: [high, medium, low]摆在面前它就不会乱写高级或者High。你可以把这份 Schema 与拼音示例一起放进提示词让模型照猫画虎。2.5 选型结论根据自己的模型先说清楚我把四条路线放在一起对比过最终结论如下路线格式保证字段保证模型兼容性适用场景JSON Mode只保证合法 JSON不保证较好临时抽取、简单脚本Tool Calling高高主流模型均支持Agent 问答器首选strict JSON Schema高高仅部分商业模型对格式要求极苛刻的线上服务纯提示词解析兜底低低所有模型本地模型降级方案我最后定了主线方案面向 API 模型时优先用 Tool Calling 提交结构化结果面向本地模型时则用 JSON Schema 示例 解析校验重试兜底。无论哪种模型只负责填内容字段定义、类型检查、重试策略全部交给代码不让模型做结构决策。3. 搭一个可复现的问答器数据模型、提示词与主流程3.1 项目结构与依赖这个问答器我拆成了四个部分数据模型、提示词模板、API 调用、解析校验。依赖只用了两个核心库openai1.30.0 pydantic2.6.0为什么用openai库因为现在大部分模型服务都提供 OpenAI 兼容接口——DeepSeek、Ollama、vLLM、甚至一些企业私有网关都是这个协议。这样代码只需要改base_url和model就能在不同供应商之间切换。3.2 数据模型把问答对象钉死在 Schema 里数据模型沿用上一节定义的QAAnswer但我加了一个校验方法用来处理模型输出中常见的答案过长问题from pydantic import BaseModel, Field, field_validator from typing import Literal class QAAnswer(BaseModel): answer: str Field( description对用户问题的中文回答200字以内 ) evidence: list[str] Field( default_factorylist, max_length3, description支撑答案的关键依据摘要最多3条 ) confidence: Literal[high, medium, low] Field( default_factorylow, description模型对答案的置信度 ) needs_followup: bool Field( defaultFalse, description是否需要用户补充信息才能给出准确答案 ) related_questions: list[str] Field( default_factorylist, max_length3, description用户可能继续追问的问题最多3个 ) field_validator(answer) def clamp_answer_length(cls, v): if len(v) 300: return v[:300] ... return v这里有个小技巧confidence我给了一个默认值low不是因为我希望模型答案都低置信而是为了在字段缺失时不至于直接崩溃。系统可以默认保守处理总比报错强。3.3 提示词模板给示例比给形容词有效结构化输出的提示词我踩过最大的坑就是形容词太多、示例太少。早期版本写的是请以严格JSON格式返回结果模型给我返回一个带有大量解释文本的 JSON。后来我改成目标函数说明 字段表 JSON 示例三段式效果立刻稳定。SYSTEM_PROMPT 你是一个结构化问答器。无论用户问什么你都通过 call_submit_qa_answer 工具提交回答。 要求 1. answer 字段直接回答问题不要前缀不要 Markdown 符号不要使用以下是回答这类废话。 2. evidence 字段填支撑答案的关键依据摘要尽量简洁最多3条如果问题不需要引用依据就填空数组。 3. confidence 只能从 high / medium / low 中选择。 4. needs_followup 为 true 时表示用户问题信息不足你无法给出可靠答案此时 answer 可以写向用户追问什么信息。 5. related_questions 最多3个必须是用户可能真的会追问的问题不能凑数。 注意第五点不能凑数这种表达不是给感情色彩是在压制模型一种常见行为——为了把三个槽填满硬编三个无关问题。类似的约束都可以写进提示词但前提是后面必须有解析层拦截不能只靠模型自觉。3.4 主流程请求、解析、校验、重试一次核心的调用函数我这样写import json from openai import OpenAI client OpenAI( base_urlhttps://your-endpoint/v1, # 换成实际服务地址 api_keyyour-api-key ) MODEL gpt-4o-mini def ask_question(question: str, history: list[dict] | None None) - QAAnswer: messages [{role: system, content: SYSTEM_PROMPT}] if history: for item in history: messages.append({role: user, content: item[user]}) messages.append({role: assistant, content: item[assistant]}) messages.append({role: user, content: question}) for attempt in range(2): resp client.chat.completions.create( modelMODEL, messagesmessages, tools[QA_TOOL], tool_choice{type: function, function: {name: submit_qa_answer}}, temperature0.2, ) # 从 tool_calls 里取结构化参数 tool_call resp.choices[0].message.tool_calls[0] raw tool_call.function.arguments try: data json.loads(raw) return QAAnswer(**data) except (json.JSONDecodeError, ValidationError) as exc: # 把错误信息拼回上下文让模型带着反馈重新生成一次 messages.append({ role: assistant, content: raw, }) messages.append({ role: user, content: f你上一次输出的结构化结果校验失败错误信息{exc}。 f请重新调用 submit_qa_answer确保字段完整、类型正确不要用 Markdown 包裹 JSON。 }) raise RuntimeError(两次尝试后仍无法生成合法结构)重点看解析校验这段。第一次尝试失败时我不是简单地把同一请求再发一遍而是把错误信息原样喂回给模型让它知道自己错在哪。这个带修正信息的重试效果出奇好成功率能从 85% 拉到 99% 以上。真实场景里 prompt 中的报错信息不能是简单的 JSON schema 报错模型读不懂最好把人类可读的 Pydantic 错误串进去。3.5 运行效果演示跑一个真实问题看看answer_obj ask_question(什么是 TCP 三次握手) print(answer_obj.model_dump_json(indent2))输出示例{ answer: TCP 三次握手是建立 TCP 连接时的三次报文交换过程客户端发送 SYN服务端回应 SYNACK客户端再发送 ACK。, evidence: [ 客户端发送 SYN 报文进入 SYN_SENT 状态, 服务端收到后回复 SYNACK 进入 SYN_RCVD 状态, 客户端收到后发送 ACK双方进入 ESTABLISHED 状态 ], confidence: high, needs_followup: false, related_questions: [ 为什么 TCP 建立连接需要三次握手而不是两次, SYN Flood 攻击和三次握手有什么关系, TCP 四次挥手和三次握手的区别是什么 ] }到这一步前端拿到的是稳定的answer字符串、evidence数组、confidence枚举值后端可以直接把evidence写入知识库索引评测脚本也能用answer_obj.confidence做聚合统计。整个问答器算是跑通了。4. 实测中的意外情况六类翻车现场与对应修复4.1 一次完整的排查链路从报错到根因方案跑通之后我做的第一件事就是换模型压力测试结果第一轮就翻车了。日志里连续报json.JSONDecodeError: Expecting property name enclosed in double quotes。我当时的排查链路是这样的先打印raw原始字符串发现输出被 Markdown 代码块包裹了json { answer: ... }2. 去掉代码块继续解析仍然报错。 3. 打印 repr(raw)留意到 JSON 的 key 竟然是中文全角引号“answer”。 4. 根因逐渐清晰系统 Prompt 的 JSON 示例里我顺手用了中文全角引号模型直接照抄进 JSON 结构导致解析失败。 修复分两层第一层是解析前加一个清理步骤把全角引号、代码块标记剥离第二层是修正 Prompt 示例本身的引号从源头避免模型学坏。两层一起做之后这类问题基本绝迹。 这个排查过程虽然简单但说明一个问题**结构化输出的稳定性不是单一环节保证的而是提示词、解析器、重试策略三层叠加出来的**。 ### 4.2 六类高频翻车现场与修复对照 我把后续一段时间遇到的解析失败案例做了分类下面这六类最典型。 | 翻车类型 | 现象 | 根因 | 修复方式 | |---|---|---|---| | Markdown 包裹 | 输出外层带 json | 模型默认偏好 Markdown | 增加正则剥壳剥离 和多余文本 | | 字段缺失 | 缺少 related_questions直接校验失败 | 模型省略不重要的字段 | required 强制约束 Pydantic 默认值兜底 | | 中文引号污染 | JSON key 出现全角引号 | Prompt 示例带了中文引号模型照抄 | 清理解析 修正 Prompt 示例 | | 枚举类型漂移 | confidence 返回 High 或 0.8 | 模型不理解枚举语义 | enum 硬校验失败后带错误重试 | | 数组超长或为空 | related_questions 给了 8 个或干脆为 [] | 缺少数量约束 | max_length3 截断 提示词明确最多3个 | | 长回答截断 | JSON 后半部分被切断括号不闭合 | max_tokens 不够 | 调大 max_tokens重试时让模型压缩答案 | 这里特别说一下枚举类型漂移。Literal[high, medium, low] 是 Pydantic 的严格枚举模型只要返回 High 或者 high 带空格都会报错。有人会觉得这太死板我的看法恰恰相反——**结构化输出就是要死板死板才能换来下游的简单**。与其在业务代码里到处处理大小写和空格变体不如把压力留在校验层一次纠正到位。 ### 4.3 那些重试也救不回来的情况 最后一类必须单独说有些问题重试两次依然失败最典型的是字段冲突。 比如我让模型同时返回200字以内回答和尽量详细的解释模型在压缩和详细之间反复横跳输出每次都能通过 Pydantic 校验但语义质量越来越差。这种冲突不是解析层能解决的得回到需求设计要么只保留简短回答统一用 related_questions 引导深入要么干脆加一个 detail 字段把短回答和长解释分开存而不是用一个字段承担两个目标。 遇到这类情况中心化策略必须是**先检查 Prompt 里有没有自相矛盾的要求**再检查 Schema 是否过度设计。结构化输出救不了逻辑冲突它只能把冲突暴露得更明显。 ## 5. 从单轮问答到 Agent 化结构化输出如何成为多步推理的地基 ### 5.1 追问推荐直接变成按钮 单轮问答跑通后最先受益的是交互层。related_questions 字段从 JSON 直接映射成界面上的三个按钮用户点哪个就把哪个作为下一轮 user 输入重新发起请求。整个过程前端不需要任何解析逻辑后端也只是把按钮文字当作普通问句处理。 这个看起来很简单的功能在自由文本输出时代是做不到的。因为那时候推荐问题藏在答案段落里前端必须靠正则挑出来挑错了用户点击之后还可能带上一堆 Markdown 符号。现在它就是数组里的几个字符串干净利落。 ### 5.2 多轮对话的记忆把 QAAnswer 直接写进历史 实现多轮追问时有个更隐蔽的收益**上一轮的 QAAnswer 可以直接作为对话历史传给大模型**。 python history [] history.append({user: 什么是 TCP 三次握手, assistant: last_answer.model_dump_json()}) next_answer ask_question(那为什么不是两次握手, historyhistory)因为last_answer是合法 JSON 字符串模型能直接从related_questions、evidence这些结构化字段里理解上一轮发生了什么而不是从人类语言的混排版式里猜测。说白了结构化输出把对话状态变成了机器可读的上下文Agent 的多轮记忆不再依赖模型能不能读懂上一条自然语言。5.3 Agent 运行框架里的定位它是契约层而不是格式工具做到一半再回头看我才理解这类结构在 Agent 体系里真正的定位它是 Agent 运行框架Harness里的契约层。一个完整的 Agent 循环包括任务拆解、调用工具、接收结果、迭代推理、最终输出。这个循环里每一步都在交换信息大模型给工具调用参数工具返回执行结果中间还要把状态写进记忆。如果这些信息不是结构化对象整个循环就是在一团乱麻里做正则匹配。这也是为什么 LangChain、Dify、CrewAI 这类框架都会有 OutputParser、ResponseFormat、Tool Schema 这些概念。它们解决的是同一件事让大模型和系统之间的每一次交互都有明确的数据契约。你的问答器哪怕不用框架只要把 Schema 定义清楚本质上就是在手写一个小型契约层。5.4 换到其他技术栈思路完全一样我最早这套是用 Python 写的后来同事在 Node.js 和 Rust 侧也做了类似实现。Node 端用 zod 定义模式再转成 JSON Schema 喂给模型Rust 端用serde_jsonschemars把struct直接生成 JSON Schema校验交给serde字符串解析错误还能拿到精确的行列位置。思路完全一致先有结构定义再有模型调用最后有校验兜底只是语法不同。5.5 关于并发和性能说句实在话热搜里总有人问AI Agent 怎么扛并发我在这篇也说下个人观点结构化输出和并发是两件事。并发压力在 API 网关、连接池和模型服务那一层解决不要指望输出格式替你扛。但如果你的模型输出层不稳定导致大量重试那并发能力会被无效请求白白消耗掉。把结构化输出的稳定率从 80% 提到 95%等同于减少了 75% 的重试流量这在并发场景下比任何框架调优都管用。从我个人经验来看结构化输出问答器不是某个模型的特权功能而是一整套围绕 Schema 的工程方法。踩坑多了之后我现在做任何 Agent 功能都会先写数据模型再写提示词最后才接模型调用。每次请求的原始返回也一定会留一份日志排查问题比看 SDK 的调试输出高效得多。这套习惯比记住任何一个模型的参数都值钱。