ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Agent实践:结构化输出问答器从设计到工程落地的完整拆解

Agent实践:结构化输出问答器从设计到工程落地的完整拆解 做Agent开发这段时间我最大的感触是模型生成文字的能力早就不是瓶颈真正卡住项目进度的是输出格式。尤其做问答器这类偏产品的Agent上游接检索系统、下游接前端页面、旁路要落数据库每一个环节都在等一份字段稳定、类型明确的JSON。这就是我做Agent实践4-结构化输出问答器这个项目的直接动机。这个项目要做的事很具体让Agent不仅会回答问题而且把回答按预定义的JSON Schema返回——答案、置信度、引用来源、建议追问全部各归其位。它对标的痛点是那种答案看起来对但程序根本没法用的尴尬局面。如果你正在做Agent应用开发或者已经受够了写正则去解析模型自由文本这篇经验分享应该对你有用。我会把整个项目的设计思路、选型逻辑、实现细节和翻车记录完整拆开讲。1. 为什么问答器的输出必须是结构化的1.1 自由文本输出的真实痛点先说个具体场景。我早期做的问答Agent给用户回答问题时输出的是纯文本。当时觉得没问题人能看懂就行。直到有一天产品经理提了个需求所有回答都要自动生成客服工单如果用户问的是价格、库存、售后政策系统要自动分类并填入CRM。问题一下子暴露了。模型回答这款降噪耳机的价格是199元目前有黑色和白色两个颜色可选黑色款缺货这行字人类看着很舒服但程序怎么识别价格是多少颜色有哪些哪个缺货我当时写的解析逻辑是先匹配价格是后面跟的数字再找可选前面列举的颜色再用缺货做库存判断。一个场景一套正则脆弱得不堪一击。产品稍微换个问法比如这耳机卖多少钱、199块能拿下吗回答句式一变解析就崩。这不是个别现象。所有下游系统——不管是前端渲染、数据库写入、报表统计还是另一个Agent的输入——本质上都希望拿到结构化数据。模型的自由文本对人类是友好的但对程序就是一场灾难。1.2 结构化输出的定义与边界所谓结构化输出简单讲就是让模型输出严格遵守预定格式的数据。最常见的载体是JSON配合一套明确的Schema约束哪些字段必须有、字段什么类型、枚举值有哪些范围、嵌套结构长什么样。以这个问答器为例我定义的输出结构大致是{ answer: 回答正文, confidence: 0.92, category: 价格咨询 | 售后政策 | 产品参数 | 库存查询 | 其他, sources: [来源文档1, 来源文档2], follow_up: [建议追问的问题1, 建议追问的问题2], needs_human: false }这里有几层约束confidence必须是0到1之间的数字category只能是五个枚举值之一sources必须是字符串数组follow_up可以空但不能缺needs_human必须是布尔值。为什么设计成这样因为下游消费方各不相同前端根据category渲染不同模板运营系统根据confidence决定是否需要人工复核知识库根据sources追踪回答依据。一旦格式稳定整个链路都是确定性的代码好写测试好做出问题也好定位。不过要说明一点结构化输出不等于死板输出。它约束的是数据的形状不是内容的质量。模型在answer字段里还是可以自由发挥的结构化只解决程序能不能稳定消费的问题。理解了这个边界后面的设计就不会走偏。2. 结构化输出问答器的整体设计与技术选型2.1 先画清数据流问答器的三个关键环节动手写代码之前我习惯先把一次完整问答的数据流画出来。这个项目的流程不复杂本质上就三个环节接收用户问题做必要的清洗和意图预判从知识库检索相关文档片段拼装Prompt上下文调用模型生成回答强制按Schema输出做校验和兜底可能有朋友会问第一个环节意图预判是不是冗余如果问题都进大模型直接回答不就行了。这里有个实战考虑问答器往往会被别的系统调用比如客服机器人转发、工单系统自动回复我必须知道这个请求是想问价格还是想退换货。提前做一次轻量分类可以决定检索策略比如价格类问题优先查报价表售后类问题优先查政策文档检索质量会明显提升。第三个环节是整个项目的核心也是后面重点展开的部分。流程上用一句话概括就是模型输出 - 格式校验 - 字段修正 - 降级重试 - 返回。校验不过不进入下游这是底线。2.2 框架选择LangChain、Dify、CrewAI还是裸调API这个项目最大的选型纠结在于要不要上Agent框架。热门框架我基本都试过各有各的脾气直接说结论。方案优势劣势适合场景LangChain生态全、组件丰富、文档多抽象层厚、版本变动大、Debug成本高需要复杂链式编排的项目Dify可视化编排、零代码门槛、内置知识库自定义逻辑受限、深度定制麻烦业务团队快速搭应用CrewAI多Agent协作方便、角色化清晰单Agent场景杀鸡用牛刀、并发能力弱多角色分工的复杂任务裸调API Pydantic完全可控、依赖少、出问题一眼能定位需要自己写编排和工具逻辑核心逻辑简单的垂直场景这个结构化输出问答器核心逻辑谈不上多复杂检索、拼接、生成、校验。用LangChain反而要应付各种抽象概念比如Chain、Runnable、OutputParser之间的兼容问题学框架的成本比写业务逻辑还高。Dify试了一下简单场景确实快但我要的字段级校验、重试策略、置信度计算都得在它之外的代码里做等于框架管一半我管一半反而别扭。最终我选了裸调API Pydantic的轻量方案。理由很简单项目边界清晰没有多Agent协作没有复杂的工具调用所有需要灵活控制的地方都在模型调用层和校验层。自己掌控这两层比在框架的缝隙里调试要踏实得多。模型侧的选型核心要求是必须支持JSON模式或函数调用Function Calling。现在主流的模型基本都支持response_format参数指定JSON输出或者通过tools参数让模型按函数签名返回结构化参数。两个方式我都测过差异和使用场景在后面实现章节细说。3. 核心实现从Prompt约束到Schema校验的完整链路3.1 先定义你的输出Schema结构化输出的起点不是Prompt而是Schema。我习惯先用Pydantic把输出结构定义成数据模型一个模型对应一个输出规格这样类型约束、默认值、枚举检查全都有了。from pydantic import BaseModel, Field from typing import List, Literal, Optional class QAResponse(BaseModel): answer: str Field(description回答正文必须直接回应问题不超过200字) confidence: float Field(ge0.0, le1.0, description回答置信度0到1之间) category: Literal[价格咨询, 售后政策, 产品参数, 库存查询, 其他] Field(description问题分类) sources: List[str] Field(description回答依据的文档标题列表可为空列表) follow_up: List[str] Field(description建议追问的问题最多3个无则空列表) needs_human: bool Field(description是否需要人工介入处理)定义Schema有几个细节值得注意。第一每个字段的description要写清楚因为模型会读到这些描述它是Prompt的一部分。第二枚举值category必须给出明确的选项让模型做选择题而不是填空题准确率会高很多。第三sources和follow_up这种可能为空的字段宁可让模型返回空列表也不要允许缺失缺失字段在校验层很麻烦。3.2 Prompt层的结构化约束技巧Schema定了接下来要让模型严格按它输出。这部分我踩过不少坑总结出一个三层Prompt的写法。第一层是角色和任务说明。告诉模型它是一个企业知识库问答助手面对的是客户咨询回答要准确、简洁、有依据。第二层是输出格式硬约束。直接告诉模型你必须输出JSON对象且只输出JSON对象不要输出任何其他文字这句话看着简单实测比不说强太多。很多模型不约束的话会在JSON外面包一层解释文字比如根据您的问题我的回答是{...}解析直接失败。第三层是Few-shot示例。给一个完整的输入输出对让模型照着样子来。示例的价值在于让模型看到边界多少算简洁、字段内容大概是多长、枚举值怎么选。这是抽象描述做不到的。SYSTEM_PROMPT 你是企业知识库问答助手。请根据提供的知识片段回答用户问题。 输出要求 1. 只输出一个JSON对象不要包含任何前后缀文字。 2. JSON必须包含以下字段answer, confidence, category, sources, follow_up, needs_human。 3. answer字段必须直接回答问题不超过200字。 4. category必须从以下枚举中选择价格咨询, 售后政策, 产品参数, 库存查询, 其他。 5. confidence表示你对回答的把握程度范围0到1没有依据时不得高于0.5。 6. sources列出回答所依据的文档标题没有依据时返回空列表。 7. follow_up提供用户可以继续追问的问题最多3个。 8. needs_human为true表示需要转人工处理。3.3 模型侧的强制JSON输出配置Prompt约束了意愿模型侧的参数约束了能力。我用的是支持JSON模式的接口调用时显式声明输出格式。from openai import OpenAI client OpenAI(api_keyAPI_KEY, base_urlAPI_BASE) def structured_qa(question: str, contexts: list[str]) - dict: context_text \n\n.join(f[{i1}] {c} for i, c in enumerate(contexts)) user_prompt f请回答以下问题{question} 知识片段 {context_text} resp client.chat.completions.create( modelyour-model-name, messages[ {role: system, content: SYSTEM_PROMPT}, {role: user, content: user_prompt}, ], response_format{type: json_object}, temperature0.2, ) content resp.choices[0].message.content return json.loads(content)这里有个关键参数是temperature0.2。结构化输出任务要的是稳定和可复现不是天马行空的创意温度调低对格式遵守有明显帮助。我试过默认的1.0模型偶尔会在JSON里加些莫名其妙的字段或者answer里写出很飘的话。0.2这个值在表达流畅和格式稳定之间比较平衡。另一个可选方案是函数调用。把Schema转换成工具签名传给模型让它以参数形式返回结构化内容。这个方式在需要同时既回答问题、又提取结构化信息的场景更好用因为模型可以一边生成自然语言回答一边附着结构化元数据。但纯问答器场景下两种方式差别不大JSON模式更直接代码也更少。3.4 校验兜底不能信模型必须信代码模型返回了JSON不等于万事大吉。我把校验流程设计成四步每一步都不是多余的。第一步json.loads能不能解析。这关就卡掉了不少情况尤其是模型在JSON前后加了多余文字的直接解析失败触发重试。第二步Pydantic模型校验。字段缺失、类型错误、枚举越界、数值范围超限在这一步全部暴露。try: parsed json.loads(content) result QAResponse.model_validate(parsed) return result.model_dump() except (json.JSONDecodeError, ValidationError) as e: # 校验失败进入重试或降级逻辑 fallback_result retry_with_fix(question, contexts, content, e) return fallback_result第三步语义合理性检查。类型对上了还要看内容合不合理。比如confidence是0.95但sources是空列表这就自相矛盾——高置信度总得有依据撑着。再比如category填价格咨询但answer里完全没提价格分类和内容对不上。这些规则我会写成校验函数比类型检查更贴近业务。第四步触发重试时把上次的报错信息回传给模型。这一步相当有效。在重试Prompt里加入你上一次的输出不符合要求错误原因是字段category取值不在枚举范围内请修正后重新输出模型大多能自己纠错。实测重试一次的修复成功率在八成以上基本不需要第二次重试。这套Prompt约束 - 模型参数约束 - 代码校验 - 带错重试的链路跑通之后我线上服务的结构化输出成功率从最初的不到70%稳定到了95%以上。剩下的兜底是校验重试两次仍失败时直接走needs_human: true的低配回复模板宁可把用户转人工也不能给出程序无法消费的脏数据。4. 实测翻车现场结构化输出的四个典型坑4.1 坑一模型返回了合法JSON但字段类型全错一次联调测试里下游BI系统拿到的confidence字段出现了92.5%字符串带了百分号。整个数据管道直接报错。我第一反应是校验没过但日志翻出来发现JSON是合法JSON字段名一个不少就是confidence的值是字符串。这类类型错位是结构化输出最阴的坑——校验层面明明通过了因为模型的输出在纯结构上合法但字段类型跟Schema不一致。Pydantic默认的校验模式对类型不匹配有时会做智能转换字符串92.5被转成浮点数反而掩盖了问题。我的应对措施是给Pydantic加上严格模式同时明确禁止字段值自动类型转换。class QAResponse(BaseModel): model_config {extra: forbid, str_strict: True} # 字段定义略严格模式的额外好处是extraforbid。如果模型在JSON里多塞了一个Schema里没有的字段直接校验失败进入重试避免脏字段流到下游。宁可多一次重试也不放过一个未知字段。4.2 坑二嵌套结构的薛定谔字段项目中期往Schema里加了一个嵌套结构用来表示回答涉及的多个商品实体每个实体有名称、编号、价格。加上后我发现一个诡异现象同一批问题有时返回的嵌套结构完整有时entity_id字段突然消失有时整个entities数组为空。排查下来发现是Prompt里的描述不够精确。我只写了entities是回答中涉及的商品列表模型对涉及的理解很浮动。参数类问题它认为所有商品都要列价格类问题又觉得只需要列一个。模型不是不会输出嵌套JSON而是对嵌套结构的语义边界理解模糊。这个坑的解法是正则化消息不够的——我在Schema的字段描述里写清楚了触发条件和排除条件entities仅列出回答中明确提及的商品未提及的价格对比商品不列出。每个实体必须包含name、entity_id、price三个字段price为数字单位统一为元。描述得越像规则模型遵循得越好。这个细节调整之后嵌套结构缺失率下降了60%以上。4.3 坑三多轮对话里结构化状态被冲掉问答器上线后接了多轮对话需求。用户在追问那白色款呢时系统需要结合上文判断那指的是什么商品。我在多轮模式下遇到了个奇怪问题单轮测试输出正常一旦开启多轮模型偶发输出纯文本而不是JSON。根因排查了很久最后发现是多轮对话的历史消息里塞了太多东西。我把历史记录参数max_history_tokens开得很大导致对话轮次多时上下文特别长模型的总注意力被历史消息稀释对系统Prompt末尾那段JSON输出约束的关注度下降。排在Prompt前面的历史内容越长排在后面的格式约束越容易被忽略。这个问题在长对话场景几乎是必然发生的尤其是上下文超过模型窗口一半时。我的解决思路是双管齐下第一限制携带的历史轮数系统只保留最近的4轮有效对话更早的内容压缩成摘要第二每一轮用户发问时都在用户消息里重新强调一遍输出要求请以JSON格式回答字段含义与系统提示一致。把格式约束从系统消息尾部的交代变成每次对话都出现的明示问题才彻底消失。4.4 坑四并发场景下的Token峰值失控这个坑更多属于工程侧。问答器接入线上流量后并发一上来Token消耗直线飙升。分析日志发现一个反直觉的现象消耗最多的不是回答本身而是重试。:结构校验失败 - 带错重试的机制确实保证了成功率但每次重试都要重新调用一次模型Token消耗翻倍以上。更糟的是我的重试逻辑是把完整上下文原样传给模型只追加一句你上次输出格式不对。长上下文的重试成本尤其高。优化后我把重试请求做了两处调整一是尽量只传与修复相关的最近一轮信息历史记录适当裁剪减轻模型负担二是给重试设置了独立的小Token预算修复JSON格式不需要长篇大论控制在回答长度的三分之一就够了。这两步下来单次失败的平均重试成本降低了约四成。并发本身的处理上也踩了个小坑直接看结论给模型调用加超时和重试熔断是对的但不要每个请求都无限重试。我设置了最多2次模型调用1次正式1次修复超过就降级返回人工处理模板这一条很大程度保住了接口的响应延迟。5. 让问答器从能跑到能扛活5.1 记忆设计结构化上下文怎么存问答器一旦支持多轮就得考虑记忆。我的做法是把每一轮结构化问答的结果存成统一的记录包括用户问题、标准后的Schema字段、检索到的来源文档、置信度。这些记录同时干两件事一是作为多轮对话的历史上下文参与后续生成二是沉淀成可分析的数据资产。存储选型上我用KV存储Redis做短期会话记忆按会话ID存最近5轮结构用向量库做长期知识沉淀把每个高置信度问答对转成向量后续遇到相似问题可以复用历史答案减少模型调用。这个设计有点接近Agent记忆的分层思路短期记忆管对话连贯长期记忆管经验复用。有一个存储细节值得单独说千万不要直接把原始answer文本存进KV就完事。我吃过亏——多轮对话回填历史时一次性把5轮的原始文本全塞进上下文Token消耗巨大还没什么帮助。正确的做法是存结构化字段的精简版本回填时只携带用户问题摘要 answer category confidence其他字段如sources可以截断或丢弃。上下文是稀缺资源每一轮记忆都在跟Token预算抢空间。5.2 并发与成本控制Token才是控制成本很多Agent项目死在成本上。结构化输出问答器的成本大头就是模型Token控制Token有四个有效手段我实测都管用。第一是检索质量优先。拼进Prompt的知识片段宁缺毋滥我之前发现检索Top5片段全部塞进去和只取相关性最高的前2条回答质量差别不大但Token消耗差了近一倍。用重排模型先过滤一遍整体成本能省不少。第二是结果缓存。问题标准化之后算一个哈希key完全一样或高度相似的问题直接命中缓存返回历史结构化回答连模型都不用调。我从上线第一周就开始统计缓存命中率稳定在30%上下这部分省下的成本相当可观。第三是动态长度控制。Schema里的answer我限制在200字以内follow_up最多3条这些字数护栏不仅是为了格式统一更是为了让模型别在无关字段上浪费Token。第四是并发限流。模型API的并发不是越高越好无限制地开并发会触发服务端的限流甚至报错反而拉高失败重试率。我在网关层做了基于令牌桶的限流控制到服务端建议并发数的70%左右留出缓冲。实测这个水位下错误率最低整体吞吐反而比满负荷冲更高。5.3 渐进式交付先做单轮结构化再加工具调用这个项目最后想分享的是一条实践路线也是我总结出的Agent开发经验千万别一上来就把所有能力都堆上去。我最初做的版本只有知识库问答结构化输出没有联网搜索、没有操作工单、没有跨系统查询。先把回答格式这条主链路跑通线上稳定运行几周积累了真实用户问题数据后才逐步加工具调用。比如用户问当时买的价格为什么和现在不一样我先通过结构化字段判断这是价格咨询然后触发订单查询工具而不是硬答最后再走一遍结构化输出链路。这种渐进式交付在工程上有个容易被低估的好处每加一个能力你都能通过线上数据判断它对结构化输出成功率的影响。加工具调用后我明明发现needs_human比例上升了立刻意识到工具返回的数据让模型更容易编造不存在的字段内容于是专门在Prompt里加了工具未返回的信息不得虚构相应字段置为其他或留空这条规则。如果一口气做完全部功能再上线这类问题排查起来会非常困难。我在这个项目里最深的体会是结构化输出不是一个Prompt技巧而是一条完整的工程链路——Schema设计、模型配置、校验兜底、重试策略、成本控制每一环都在影响最终成功率。评测一个问答器好不好用不能只看回答得准不准更要看输出能不能被程序稳定消费。后者才是Agent进入生产环境的前提。最后分享一个小经验所有的结构化输出规则都要用线上真实数据持续反测。模型API每隔一段时间更新行为会有细微变化上周成功率95%的方案这周可能掉到90%。我在系统里加了针对结构化输出的手工抽检任务每次抽查30条线上记录能第一时间感知这类波动。做Agent应用对模型的不可控性保持敬畏不是一句空话把它落实到监控和预案上系统才能长期站得住。
返回列表