1. 为什么结构化输出是智能问答系统落地的关键一环做过企业级问答系统的人都有一个共同体会模型能不能“说人话”只是第一步能不能“按格式说话”才是决定这套系统能不能真正接入业务流程的分水岭。我在前几年做内部知识助手的时候最开始就是让模型自由发挥结果前端拿到的是一段自然语言想解析出“订单号”“处理状态”“建议动作”这些字段全靠正则硬抠维护成本高得离谱稍微换个问法就崩。后来我们把思路转到结构化输出上让模型直接吐JSON整个链路才顺起来。这一章要聊的就是智能问答系统从“能回答”走向“能干活”的两个核心能力结构化输出和工具调用。前者解决的是“模型输出能不能被程序稳定消费”的问题后者解决的是“模型能不能主动去查数据、调接口、执行动作”的问题。这两个能力合在一起才算是真正意义上的企业级问答系统——它不只是个聊天窗口而是能对接订单库、工单系统、CRM、监控平台的智能中枢。适合谁来读这篇如果你正在做RAG问答、智能客服、Agent应用或者你是个后端/全栈工程师被产品经理要求“让AI返回能直接入库的结果”那这篇内容基本就是给你写的。我会把JSON Schema怎么设计、参数怎么校验、工具怎么注册、调用失败怎么兜底这些实操细节全部摊开讲代码能直接抄。先给个整体认知结构化输出不是简单地“让模型返回JSON”这么粗暴。它背后涉及输出约束、Schema定义、解析容错、重试机制一整套工程。工具调用也不是“给模型几个函数”就完事它涉及工具描述设计、参数校验、权限边界、调用链路追踪。这两块做不好系统上线就是灾难现场——模型偶尔返回个带注释的JSON你的解析器直接抛异常整个对话就断了。2. 结构化输出的核心设计与Schema落地2.1 从“让模型返回JSON”到“约束模型必须返回合法JSON”很多人第一次做结构化输出提示词就写一句“请以JSON格式返回”然后祈祷模型听话。实测下来这种方式在简单场景下能到80%左右的成功率但一旦字段多了、嵌套深了模型就开始自由发挥有的加json代码块标记有的在JSON前后加解释文字有的把数字写成字符串有的干脆漏字段。企业级系统不能接受这种不确定性。正确的做法是双重约束提示词层面明确Schema解码层面用结构化输出能力强制约束。现在主流的大模型API基本都支持JSON Mode或者Structured Output前者只保证输出是合法JSON后者能保证严格符合你给的Schema。如果用的模型支持Structured Output优先用它这是最稳的。我一般会把Schema用JSON Schema格式定义好既传给模型做提示也用于后端的参数校验。这样一份Schema两处用不会出现“提示词里写的和后端校验的对不上”这种低级错误。举个实际例子一个工单查询的问答场景模型需要返回这样的结构{ intent: query_ticket, confidence: 0.92, entities: { ticket_id: TK20260101, status: processing }, reply: 您的工单正在处理中预计24小时内完成。, need_human: false }对应的JSON Schema大概长这样{ type: object, properties: { intent: { type: string, enum: [query_ticket, create_ticket, cancel_ticket, unknown] }, confidence: { type: number, minimum: 0, maximum: 1 }, entities: { type: object, properties: { ticket_id: { type: string, pattern: ^TK[0-9]{8}$ }, status: { type: string } }, required: [ticket_id] }, reply: { type: string, maxLength: 500 }, need_human: { type: boolean } }, required: [intent, confidence, reply, need_human] }注意几个设计细节。intent用enum限定这样模型不会造出你没定义过的意图后端switch分支就不会漏。confidence限定0到1避免模型返回个1.5或者high这种没法用的值。ticket_id用正则pattern约束从源头保证格式统一。reply限制maxLength防止模型话痨输出超长文本撑爆前端。这些约束看起来琐碎但每一条都是踩过坑之后加的。2.2 Schema设计的三个原则扁平优先、必填明确、枚举兜底Schema设计有几个我总结的原则分享出来。第一能扁平就扁平嵌套别超过三层。模型对深层嵌套的字段填充准确率会明显下降尤其是数组套对象再套对象这种。我见过有人设计五层嵌套的Schema结果模型在第三层就开始丢字段。如果业务允许把嵌套结构拆成多个平级字段或者用扁平化的key命名比如user_name、user_phone而不是user: {name, phone}。第二required要明确别让模型猜。JSON Schema里required数组列出的字段模型必须填。没列的字段模型可能省略。所以凡是后端逻辑依赖的字段全部放进required。但也要注意required太多会增加模型负担导致它为了凑字段而编造内容。我的经验是核心字段必填辅助字段可选可选字段在后端做默认值兜底。第三枚举是兜底神器。凡是取值有限的字段一律用enum。比如状态、类型、优先级、渠道这些。枚举不仅约束了模型还顺便给后端提供了完整的取值集合写校验逻辑的时候直接遍历enum就行。有个小技巧在enum里加一个unknown或other作为兜底值这样模型遇到拿不准的情况会归到这个值而不是硬编一个你没定义的值。2.3 解析容错模型偶尔不听话怎么办即使有Structured Output也不能保证100%不出问题。网络抖动、模型版本切换、超长上下文都可能导致输出异常。所以解析层必须做容错。我的做法是三层解析。第一层直接JSON.parse成功就过。第二层如果失败尝试提取文本中的第一个{到最后一个}之间的内容再解析这一步能救回大部分“JSON外面包了废话”的情况。第三层如果还失败走重试机制把原始输出和错误信息拼进提示词让模型重新生成一次最多重试两次。两次还失败就降级到兜底回复同时打点告警。import json import re def parse_structured_output(raw: str, max_retry: int 2): # 第一层直接解析 try: return json.loads(raw), None except json.JSONDecodeError: pass # 第二层提取花括号内容 match re.search(r\{.*\}, raw, re.DOTALL) if match: try: return json.loads(match.group()), None except json.JSONDecodeError: pass # 第三层交给上层重试 return None, parse_failed注意正则提取花括号在嵌套JSON场景下可能截断所以第二层只作为应急不能当主力。真正稳的还是Structured Output加严格Schema。重试的时候有个细节不要原样重发要把错误信息带上。比如“你上次返回的内容不是合法JSON错误是xxx请严格按Schema重新返回不要包含任何解释文字”。实测这样重试的成功率能到90%以上。3. 工具调用的完整实现链路3.1 工具调用的本质让模型学会“什么时候该查、该调、该算”结构化输出解决的是“输出格式”工具调用解决的是“能力边界”。模型本身的知识是静态的它不知道你数据库里今天的订单状态也不会算你公司特有的折扣规则。工具调用就是给模型装上“手脚”让它能主动去查、去算、去执行。工具调用的核心机制是你把一组工具函数的描述和参数Schema告诉模型模型在对话过程中判断“这个问题我需要调某个工具”然后输出一个工具调用请求包含工具名和参数你的后端执行这个工具把结果返回给模型模型再基于结果生成最终回复。整个过程模型不直接执行代码它只负责“决策”和“填参”执行权在你手里这是安全边界的关键。我见过有人担心“模型乱调工具怎么办”其实只要做好三件事就稳工具描述写清楚适用场景、参数Schema严格校验、执行层做权限和幂等控制。模型比你想象的守规矩前提是你把规矩写明白了。3.2 工具Schema怎么写才能让模型“用得对”工具Schema是工具调用的灵魂。写得好模型调用准确率90%以上写得烂模型要么该调不调要么乱填参数。我总结了一套写法。工具名要动词开头、语义明确。query_order_status比order好create_ticket比ticket_create好。模型对动词开头的工具名理解更准。description要写“什么时候用”和“什么时候不用”。这是最多人忽略的点。光写“查询订单状态”不够要写“当用户询问订单进度、物流状态、是否发货时使用。当用户询问退款政策时不要使用此工具应使用query_refund_policy”。把边界写清楚模型就不会串工具。参数描述要具体到格式和示例。比如order_id的描述写“订单号格式为ORD开头加12位数字例如ORD202601010001”。模型看到示例填参准确率会明显提升。{ name: query_order_status, description: 查询订单的当前状态和物流信息。当用户询问订单进度、是否发货、物流到哪了时使用。当用户询问退款、退货政策时不要使用。, parameters: { type: object, properties: { order_id: { type: string, description: 订单号格式为ORD开头加12位数字例如ORD202601010001 }, need_logistics: { type: boolean, description: 是否需要返回物流轨迹详情默认false只返回状态 } }, required: [order_id] } }3.3 参数校验模型填的参数不能直接信模型填的参数哪怕Schema约束了类型也不能直接拿去查库。原因有两个一是模型可能填一个格式对但不存在的ID二是模型可能被用户诱导填恶意参数。所以执行层必须做二次校验。我的校验分三步。第一步类型和格式校验用pydantic或者jsonschema库过一遍确保类型对、正则匹配、枚举合法。第二步业务校验比如order_id是否属于当前用户这个必须查库确认不能只信模型。第三步权限校验当前会话的用户有没有权限调这个工具、查这个数据。from pydantic import BaseModel, Field, validator class QueryOrderParams(BaseModel): order_id: str Field(..., patternr^ORD\d{12}$) need_logistics: bool False validator(order_id) def check_order_belongs_to_user(cls, v, values, **kwargs): # 实际项目中这里要查库确认归属 # 这里只做格式演示 return v def execute_tool(tool_name: str, raw_params: dict, user_context: dict): if tool_name query_order_status: try: params QueryOrderParams(**raw_params) except Exception as e: return {error: f参数校验失败: {e}} # 业务权限校验 if not check_permission(user_context, params.order_id): return {error: 无权访问该订单} return do_query_order(params.order_id, params.need_logistics)提示参数校验失败时不要把原始异常直接返回给模型要返回结构化的错误信息比如{error: order_id格式不正确应为ORD加12位数字}这样模型能根据错误信息修正参数重试。3.4 多工具编排与调用链路追踪企业级场景往往不是单工具而是多工具协作。比如用户问“我上周买的那个订单到哪了顺便帮我看看有没有优惠券能用”这就需要先查订单、再查优惠券可能还要算一下叠加规则。模型会依次发起多个工具调用你的后端要能串起来。这里有个关键设计每次工具调用都要有trace_id把模型决策、参数、执行结果、耗时全部记下来。出问题的时候能回溯也能用于后续优化工具描述。我一般会在会话级别生成一个session_id每次工具调用生成一个call_id日志里全部带上。多工具编排还有个坑工具返回结果太长会撑爆上下文。比如查物流返回了50条轨迹全塞回模型token直接爆炸。我的做法是工具层做结果裁剪只返回模型决策需要的关键字段详细数据让前端另外拉。比如物流只返回“最新状态最新一条轨迹”而不是全部。4. 结构化输出与工具调用的协同实战4.1 一个完整的问答回合长什么样把结构化输出和工具调用串起来一个完整的问答回合是这样的用户提问 → 模型判断意图 → 如果需要工具输出工具调用请求结构化→ 后端校验参数、执行工具 → 工具结果回传模型 → 模型生成最终结构化回复 → 后端解析、校验、入库/返回前端。我拿一个实际场景走一遍。用户问“帮我查下订单ORD202601010001到哪了如果还没发货就取消掉。”第一轮模型输出工具调用{ tool_calls: [ { name: query_order_status, arguments: {order_id: ORD202601010001, need_logistics: true} } ] }后端校验参数通过执行查询返回{ order_id: ORD202601010001, status: paid, shipped: false, logistics: null }第二轮模型基于结果判断“未发货”发起取消工具调用{ tool_calls: [ { name: cancel_order, arguments: {order_id: ORD202601010001, reason: user_request} } ] }后端执行取消返回成功。第三轮模型生成最终结构化回复{ intent: cancel_order, confidence: 0.95, entities: {order_id: ORD202601010001, action: cancelled}, reply: 您的订单ORD202601010001尚未发货已为您成功取消。, need_human: false }整个链路清晰、可追踪、可校验。这就是企业级系统该有的样子。4.2 工具调用失败的兜底策略工具不可能永远成功。超时、下游故障、参数错误、权限不足都会失败。失败之后怎么办直接决定用户体验。我的兜底策略分四级。第一级参数错误自动重试把错误信息回传模型让它修正参数重试一次。第二级下游超时降级返回“查询超时请稍后重试”同时记录不阻塞对话。第三级权限不足转人工返回“该操作需要人工协助”把need_human置为true。第四级连续失败熔断同一个工具连续失败超过阈值临时禁用该工具避免雪崩。class ToolCircuitBreaker: def __init__(self, threshold5, cooldown60): self.failures {} self.threshold threshold self.cooldown cooldown self.blocked_until {} def is_available(self, tool_name): import time if tool_name in self.blocked_until: if time.time() self.blocked_until[tool_name]: return False del self.blocked_until[tool_name] self.failures[tool_name] 0 return True def record_failure(self, tool_name): import time self.failures[tool_name] self.failures.get(tool_name, 0) 1 if self.failures[tool_name] self.threshold: self.blocked_until[tool_name] time.time() self.cooldown注意熔断阈值和冷却时间要根据工具的重要性调整。查询类工具可以宽松点写操作类工具要严格因为写操作失败重试可能造成重复下单、重复退款这种严重问题。4.3 幂等性写操作工具的生命线查询工具失败重试无所谓写操作工具失败重试可能出大事。用户说“取消订单”第一次调用超时了模型重试结果取消了两遍——虽然取消两次结果一样但如果是“退款”呢退两次就是资损。所以写操作工具必须做幂等。做法是每次写操作生成一个唯一的idempotency_key由会话ID加操作类型加业务ID组成比如session123_cancel_ORD202601010001。执行前先查这个key有没有执行过执行过就直接返回上次结果不重复执行。def execute_idempotent(idempotency_key: str, func, *args, **kwargs): cached redis.get(fidem:{idempotency_key}) if cached: return json.loads(cached) result func(*args, **kwargs) redis.setex(fidem:{idempotency_key}, 3600, json.dumps(result)) return result这个key的生成要稳定不能每次随机否则幂等就失效了。我一般用hash(session_id tool_name sorted(params))作为key保证同样的请求生成同样的key。5. 常见问题与排查技巧实录5.1 模型不调工具/乱调工具怎么排查这是最高频的问题。模型该调工具的时候不调或者不该调的时候乱调。排查思路我整理成一张表。现象可能原因排查方法解决方向该调不调工具description没写清适用场景看日志里模型的思考过程补充“什么时候用”的描述该调不调提示词没强调可以用工具检查system prompt明确告知模型有工具可用乱调工具多个工具描述边界模糊对比工具description写清“什么时候不用”乱调工具参数Schema太宽松看模型填的参数加enum、加正则、加required调了但参数错参数描述缺示例看错误参数补格式说明和示例调了但参数错用户表述模糊看原始问题加澄清追问逻辑我踩过最深的坑是工具描述写得太简略。当时有个search_knowledge工具description就写了“搜索知识库”结果模型什么问题都去搜连“你好”都要搜一下。后来改成“当用户询问产品功能、使用方法、政策条款等需要查资料的问题时使用。当用户只是打招呼、闲聊、或询问当前订单状态时不要使用”调用准确率立刻上来了。5.2 JSON解析失败的五大原因结构化输出解析失败我统计下来主要是五个原因。一是模型加了代码块标记json包了一层这个用正则剥掉就行。二是JSON前后有解释文字比如“好的这是结果{...}”用花括号提取能救。三是字段值里有未转义的引号比如reply里写了他说好的这个要靠Structured Output从源头避免。四是数字精度问题大整数被写成科学计数法这个在Schema里用string类型存ID能避免。五是模型漏字段required没设全补上required。提示生产环境一定要对解析失败做打点监控按失败原因分类统计。如果某类失败突然升高往往是模型版本更新或者提示词被改了能第一时间发现。5.3 工具调用性能优化的几个实操点工具调用会显著增加响应时间因为多了一次甚至多次模型往返。优化有几个方向。一是并行调用如果多个工具之间没有依赖让模型一次性发起多个tool_calls后端并行执行。二是结果缓存查询类工具的结果按参数缓存短时间内相同查询直接返回。三是工具结果精简只回传模型决策需要的字段别把整个数据库行都塞回去。四是流式输出最终回复用流式让用户感知上更快。我实测过一个场景串行调用三个工具耗时2.3秒改成并行后降到0.9秒体验提升明显。但并行有个前提工具之间不能有依赖而且写操作工具不建议并行避免并发问题。5.4 安全边界工具调用的红线最后强调安全。工具调用给了模型执行能力就必须有边界。第一工具白名单只注册业务需要的工具不要图省事把内部API全暴露。第二参数强校验前面说的三步校验一步不能少。第三敏感操作二次确认比如退款、删除、改价模型发起后要让用户确认再执行。第四审计日志每次工具调用记录谁、什么时候、调了什么、参数是什么、结果是什么。第五限流单会话单工具调用频率限制防止被刷。我在实际项目里还加了一条工具返回结果脱敏。比如查询用户信息手机号中间四位要打码身份证号只返回后四位。这些脱敏在工具层做不要指望模型做模型不可信。6. 我个人的一些实操体会这套结构化输出加工具调用的方案我在三个项目里落地过从内部知识助手到对客智能客服踩的坑基本都在这篇里了。最大的体会是别指望模型一次就对要把工程手段用足。Schema约束、参数校验、重试兜底、幂等熔断这些看起来是“额外工作”但正是它们让系统从demo变成能上线的产品。还有个心得是工具描述要像写API文档一样认真。很多人把工具描述当注释随便写结果模型调用准确率上不去回头怪模型笨。其实模型很聪明你描述写清楚了它用得比谁都准。我现在的习惯是每加一个工具先写description写完自己读一遍问自己“如果我是模型看到这段描述知道什么时候用吗”不知道就重写。最后分享一个小技巧用日志反哺优化。把每次工具调用的决策、参数、结果都存下来定期分析。哪些工具从没被调用过可能是描述有问题哪些工具参数经常填错可能是Schema要调整哪些问题模型反复澄清可能是意图识别要加规则。数据不会骗人优化方向都在日志里。