从“模型输出的纯文本没法直接入库”这个问题说起吧。我去年做过一个企业知识库问答项目,最开始的做法很野:让模型返回一段带标记的文本,再用正则表达式去抠字段。结果客户那边说“系统偶尔会把员工的姓名解析成部门名”,一查,是模型在长文本输出里自作主张改了字段顺序,正则匹配全错。后来我把整套逻辑迁到Llamaindex上,发现它原生就把“结构化输出”和“评估”这两件事做成了体系:用Pydantic声明输出契约,用三类评估器量化RAG效果。这篇文章就把这两块拆开讲清楚,适合已经跑通基础问答、但觉得效果不可控、想往生产环境推的人看。
1. 结构化输出:别再让模型自由发挥了
1.1 直接要求“输出JSON”,模型为什么还是翻车
很多人在第一次做LLM应用时都会试过这种写法:
你是一个信息抽取助手,请从文本中提取姓名、职位、入职年份,并以JSON格式返回。看起来很简单,实际跑起来问题一大堆。
第一个问题是字段缺失。模型心情好的时候给你四个字段,心情不好只给三个,关键字段说没就没。第二个问题是字段名漂移。你要求用employee_name,它给你返回name,或者更离谱,返回一个full_name。第三个问题是类型不稳定。你声明age是整数,它给你返回字符串“28岁”,前端拿到直接崩溃。第四个问题最隐蔽:模型在JSON外面包裹解释性文字,比如“好的,这是你要的JSON:”然后才是一段代码块,解析器直接报错。
有人会说我用正则修一下就好了。但正则本质上是在给模型的“随机行为”打补丁,今天补丁能跑,明天换一个文档、换一个prompt措辞,模型又开始自由发挥。这种方案在演示Demo里能糊弄过去,放到生产环境就是一颗定时炸弹。
1.2 Llamaindex的结构化输出思路:声明式契约
Llamaindex的思路不是“尽量约束模型”,而是“让模型按照一个可校验的契约输出”。它的核心思想是:先定义好你想要的输出结构,再把结构转换成模型能理解的形式,最后自动完成解析和校验。
这个思路和传统编程里的“接口定义”很像。写后端接口时,你先定义request和response的DTO,然后才写业务逻辑。LLM输出也应该这样,先用Pydantic声明字段、类型、约束,再让Llamaindex去处理底层的prompt构造、函数调用、结果解析。
这样做有三个直接好处:
- 字段缺失或类型错误会在本地抛出
ValidationError,而不是等到下游程序运行时才崩溃。 - 可以在Pydantic字段上写描述(
Field(description=...)),这些描述会被注入到prompt里,引导模型理解每个字段的含义。 - 拿到的是真正的结构化对象,可以直接序列化、入库、喂给下游函数。
在Llamaindex 0.10.x版本里,有三条路可以走:llm.structured_predict、PydanticProgram、以及Query Engine层直接传output_cls。下面逐一展开。
2. 锁定输出结构:Pydantic模型与解析器实战
2.1 定义你的数据契约
不管走哪条路,第一步都是定义一个Pydantic模型。我用一个音乐专辑抽取的场景做示例,假设我们要从一段乐评里提取专辑信息。
from pydantic import BaseModel, Field from typing import List, Optional class Track(BaseModel): """单曲信息""" name: str = Field(description="单曲名称") duration_seconds: Optional[int] = Field( default=None, description="单曲时长,单位秒,未知则为None" ) class AlbumInfo(BaseModel): """专辑信息""" title: str = Field(description="专辑标题") artist: str = Field(description="歌手或乐队名称") release_year: int = Field(description="发行年份") genres: List[str] = Field(description="音乐风格列表") tracks: List[Track] = Field(description="专辑包含的单曲列表")这里有两个容易被忽略的细节。
第一,Field(description=...)不是摆设。Llamaindex在构造prompt时,会把模型字段的description拼进去,模型实际上是通过这些描述来理解“该往这个字段里填什么”。字段名本身往往不够表达语义,比如release_year,模型可能不确定是首发年份还是再版年份,你在description里写清楚“发行年份”,抽取准确率会明显提升。
第二,Optional[...]和default的用法很重要。真实文本里不一定包含所有信息,比如单曲时长经常未知。如果你声明成必填字段,解析时模型为了凑字段就会编造一个数值,这比空着还要危险。标成Optional,模型在信息不足时就更倾向于留空,而不是瞎猜。
2.2 用PydanticOutputParser串联解析流程
定义好模型之后,最底层的用法是PydanticOutputParser加StructuredOutput。这段代码建议理解,因为它是后续所有高层封装的基础。
from llama_index.core.output_parsers import PydanticOutputParser from llama_index.llms.openai import OpenAI # 构造解析器,内部会根据 AlbumInfo 的 schema 生成输出格式说明 parser = PydanticOutputParser(output_cls=AlbumInfo) format_instructions = parser.format_string() # 在 prompt 里加入 format_instructions # 假设已有 llm 实例 llm = OpenAI(model="gpt-4o-mini") # 拿到模型原始输出后,交给 parser 解析 response = llm.complete( "Extract fields from the following review:\n" "《XX》是YY乐队2021年发行的第三张录音室专辑,风格以前卫摇滚为主,..." ) raw = response.text # parse 过程:先用 JSON 解析,再交给 Pydantic 校验,最终返回 StructuredOutput parsed_result = parser.parse(raw) print(parsed_result.raw_output) # 原始文本 print(parsed_result.parsed_output) # AlbumInfo 实例StructuredOutput里有两个关键属性,raw_output和parsed_output。raw_output是模型返回的原始文本,parsed_output是解析并校验后的Pydantic对象。这两个字段的区分很重要:调试时先看raw_output,能确认模型到底输出了什么;如果parsed_output为None,说明解析或校验环节出了问题。
2.3 三种主流调用方式对比
直接手动调用parser有点繁琐,日常开发我更推荐用下面三种高层封装。它们的底层最终都会走“构造prompt + 解析 + 校验”这个流程,但适用场景不同。
| 调用方式 | 典型代码入口 | 适用场景 | 备注 |
|---|---|---|---|
llm.structured_predict | llm.structured_predict(output_cls=AlbumInfo, prompt=..., ...) | 想直接调用LLM做一次结构化抽取,不经过索引/查询引擎 | 最轻量,适合单次抽取 |
LLMTextCompletionProgram/OpenAIPydanticProgram | program = LLMTextCompletionProgram.from_defaults(output_cls=..., llm=...) | 需要复用同一个抽取流程,传不同输入跑多次 | 可封装成服务 |
Query Engine传output_cls | index.as_query_engine(..., output_cls=AlbumInfo) | 基于检索问答,要求最终答案按结构化格式返回 | 和RAG链路耦合度最高 |
先看structured_predict的用法,它适合在需要临时抽取时快速调用:
from llama_index.core.llms import LLM from llama_index.core.prompts import PromptTemplate prompt = PromptTemplate( "从下面的乐评中提取专辑信息。\n" "乐评内容:{review_text}\n" "{format_instructions}" ) album = llm.structured_predict( output_cls=AlbumInfo, prompt=prompt, review_text="...", ) print(album.title, album.artist, album.release_year)再看LLMTextCompletionProgram,它相当于把上面的逻辑包装成了一个可复用对象:
from llama_index.core.program import LLMTextCompletionProgram program = LLMTextCompletionProgram.from_defaults( output_cls=AlbumInfo, prompt_template_str=( "从下面的乐评中提取专辑信息。\n" "乐评内容:{review_text}\n" "{format_instructions}" ), llm=llm, ) result: AlbumInfo = program(review_text="...")对于RAG场景,最爽的其实是第三种:直接在查询引擎上挂output_cls。这样检索、合成、结构化输出一条链路全部自动化。
query_engine = index.as_query_engine( output_cls=AlbumInfo, response_mode="tree_summarize", ) resp = query_engine.query("这套专辑的发行年份和风格是什么?") # resp.response 是序列化后的 JSON 字符串 # 但更推荐直接走自定义查询引擎,把 parsed 结果暴露出来说实话,第三种的细节在不同版本下略有差异,有些版本需要配合as_structured_query_engine这类接口。我的建议是:如果只是为了快速验证,用structured_predict;如果是做正式RAG应用,优先把output_cls挂到查询引擎上,这样最省心。
3. 结构化预测的容错与边界
3.1 为什么优先走函数调用模式
如果你用OpenAI系模型,会注意到Llamaindex在structured_predict时优先走function calling / tool calling机制。它的原理是:把Pydantic模型转成JSON Schema,注入到函数的parameters字段里,模型只生成符合schema的json,而不是自由发挥的文本。
这一步的价值在于:格式约束从“prompt里的建议”变成了“模型推理时的硬约束”。模型在函数调用模式下生成的内容必须匹配schema,字段缺失和类型错误的概率会低得多。实测下来,同一个抽取任务,用函数调用模式相比纯文本prompt模式,解析失败率能下降一个数量级。
如果你用的模型不支持函数调用,Llamaindex会退回到纯文本模式,即把schema渲染成JSON示例塞进prompt。这时候format_instructions的质量就很重要,建议在prompt里显式给出一个“好”的输出示例,比只给字段定义要稳。
3.2 解析失败时会发生什么
即使有函数调用,解析失败仍然可能发生,尤其是在模型上下文过长、输出接近max_tokens边界时。常见的失败有这几类:
- 模型截断输出,JSON不完整,
json.loads直接抛JSONDecodeError。 - 字段类型不匹配,Pydantic抛出
ValidationError。 - 嵌套模型里的数组字段为空,导致下游业务拿到空列表还以为是数据缺失。
我在代码里一般会包一层异常处理:
from pydantic import ValidationError from llama_index.core.output_parsers.base import OutputParserException try: parsed = parser.parse(raw_output) except OutputParserException as e: # 记录原始输出,方便人工检查 logger.error(f"structured parse failed, raw={raw_output}", exc_info=e) # 可以重试一次,加上“请严格按要求输出”的补充提示 ... except ValidationError as e: logger.error(f"pydantic validation failed: {e}") ...有一种情况特别值得注意:如果同一个输入反复解析失败,千万别第一时间怀疑模型能力,先回头检查Pydantic字段的description是否写清楚了。我遇到过一次比较典型的问题:字段叫summary,description写的是“内容摘要”,模型经常把整段原文塞进去,导致超出长度限制。后来把description改成“用不超过50个字概括这段文本的核心观点”,问题立刻缓解。LLM是非常依赖指令精确度的,字段描述不够明确,它就会按自己的理解发挥。
3.3 结构化输出分类任务的一个技巧
如果你需要的是分类而不是抽取,可以充分利用Pydantic的Literal类型,把候选类别直接写死在字段类型里。
from typing import Literal class FeedbackCategory(BaseModel): category: Literal["bug", "feature_request", "question", "other"] = Field( description="用户反馈的分类" ) confidence: float = Field(description="置信度,0到1之间")这比在prompt里写“请从以上类别中选择一个”要强得多。因为Literal会生成枚举约束,模型只能从给定的选项里选,从根本上杜绝了“发明新类别”的可能。从热词看,最近“分类评估”这个话题讨论度不低,其实在Llamaindex里就是Literal加一个评估器的事,后面会提到评估器怎么接。
4. 评估不是“跑个分”,是三类指标的协同配合
4.1 很多人对“评估”有个误解
先说一个类比。很多人搜“beyond compare 30天评估期已结束”、“winrar去掉评估版本”,这个“评估”指的是软件试用版试用期。但RAG系统里的“评估”完全是另一码事,它指的是:用一套可量化的指标,判断当前系统的输出质量是否达标。这个误解导致不少项目组在演示时觉得“效果不错”,一上线就被用户吐槽“答案在胡编”。
原因在于,RAG链路是“检索 + 生成”两个环节叠加。最终答案错了,可能是检索没召回相关文档,也可能是生成阶段模型没基于检索结果作答。如果只凭人的感觉判断“答得好不好”,根本定位不到瓶颈。
Llamaindex的llama_index.core.evaluation模块把这件事拆成了几个明确的评估器,我用得最多的是下面三个:
| 评估器 | 解决的问题 | 一句话解释 |
|---|---|---|
FaithfulnessEvaluator | 答案是否忠于检索到的上下文 | 检测“幻觉”,上下文里没有的信息,模型有没有瞎编 |
RelevancyEvaluator | 检索到的上下文是否与用户问题相关 | 检测“检索失效”,召回了一堆不相关内容,答案自然歪 |
CorrectnessEvaluator | 答案与标准参考答案是否一致 | 检测“最终正确性”,需要一份参考标准 |
这三个指标不是替代关系,是互补关系。正确答案可以从错误的上下文里侥幸生成,忠实度高的答案也可能因为检索资料不足而不正确。所以我习惯把它们当成一个评估组合来看,而不是单独看某一个。
4.2 单个评估器实操:以FaithfulnessEvaluator为例
下面是FaithfulnessEvaluator最小可用的示例。它内部会用LLM判断“生成的回答”是不是完全由“上下文”支撑,如果回答里出现了上下文没有的信息,评估器会打回passing=False。
from llama_index.core.evaluation import FaithfulnessEvaluator from llama_index.core.indices.query.query_transform.base import DecomposeQueryTransform from llama_index.llms.openai import OpenAI # 评估本身也是一次LLM调用,用便宜模型就够了 llm = OpenAI(model="gpt-4o-mini") evaluator = FaithfulnessEvaluator(llm=llm) # 注意是 evaluate_response,不是 evaluate result = await evaluator.aevaluate_response( query="XX乐队是哪一年成立?", response=response, # 这是 query_engine 返回的 Response 对象 ) print(result.passing) # True / False print(result.score) # 0.0 ~ 1.0 或者 None,取决于评估器实现 print(result.feedback) # 具体判断理由可能有人会问,为什么评估器本身也要用LLM,这不是嵌套调用吗?是的,LLM-as-judge是目前主流的评估方式。它的优势是能理解语义,能判断“这句话虽然表述不同但意思一致”;短板是有token成本、有一定抖动。我在项目里的做法是:评估用一个更便宜的模型,统一跑完所有测试case,能容忍少量误判,毕竟看的是整体通过率趋势。
4.3 三个评估器一起上的完整链路
RelevancyEvaluator的用法几乎一样,它把response替换成retrieved_nodes相关的查询结果,判断“检索回来的上下文”和“用户问题”是否相关。CorrectnessEvaluator则要求提供reference参考回答,判断模型答案和参考答案是否语义一致。
真实项目里,我不会只跑一个评估器,而是把它们打包成一个BatchEvalRunner,同一批query全部跑一遍。下一章就讲这个。
5. 把评估跑起来:批量Runner与评估集构建
5.1 用BatchEvalRunner批量执行
手动一条一条评估太慢了,Llamaindex提供了BatchEvalRunner来批量跑多个评估器。
from llama_index.core.evaluation import ( BatchEvalRunner, CorrectnessEvaluator, FaithfulnessEvaluator, RelevancyEvaluator, ) faithfulness_evaluator = FaithfulnessEvaluator(llm=llm) relevancy_evaluator = RelevancyEvaluator(llm=llm) correctness_evaluator = CorrectnessEvaluator(llm=llm) runner = BatchEvalRunner( { "faithfulness": faithfulness_evaluator, "relevancy": relevancy_evaluator, "correctness": correctness_evaluator, }, show_progress=True, )准备好一批query后,调用aevaluate_queries:
queries = [ "XX乐队哪一年成立?", "这张专辑的制作人是谁?", "乐队在2019年发行了什么作品?", ] eval_results = await runner.aevaluate_queries( query_engine=query_engine, queries=queries, ) # eval_results 是 dict,key 是评估器名字,value 是 EvaluationResult 列表 for metric_name, results in eval_results.items(): passing = sum(r.passing for r in results if r.passing is not None) total = len(results) print(f"{metric_name}: {passing}/{total} passed, rate={passing / total:.2%}")这里有一个要注意的点:aevaluate_queries是异步的。如果你在Jupyter Notebook里跑,需要先await或者用nest_asyncio处理;在普通Python脚本里跑,建议用asyncio.run(...)包一层。我第一次跑的时候直接同步调用evaluate_queries,发现有些事件循环嵌套的问题,后来统一改成异步写法才稳定。
5.2 评估集从哪来:文档改写成问题 + 人工抽检
评估集的构建是最容易被糊弄的环节,但也是最值得投入时间的。没有真实用户query,可以先基于文档内容人工构造一批“黄金问题”。
我习惯从三个维度构造:
| 问题类型 | 例子 | 评估目的 |
|---|---|---|
| 单跳事实题 | “XX公司总部在哪?” | 验证基础检索能力 |
| 多跳综合题 | “A项目用了哪些技术栈,和B项目有什么重叠?” | 验证多文档综合能力 |
| 否定/边界题 | “文档里有没有提到预算?” | 验证模型会不会在无依据时拒绝回答 |
数量上,30到50条起步就够了,不需要一上来就搞几百条。关键是每条都要人工标注“参考回答”或“期望行为”,这样才能给CorrectnessEvaluator提供reference。
如果预算允许,我强烈建议评估集里混入5%左右的“超纲问题”,也就是文档里完全没有答案的问题。这类问题最能暴露“幻觉”——一个好的RAG系统应该回答“未在文档中找到相关信息”,而不是编一个像模像样的答案。我的经验是,很多项目在单跳事实题上通过率能到90%,一加上超纲问题直接掉到50%以下,幻觉问题立刻现形。
5.3 别忘了跑一次基线对比
最后还有一个很容易被跳过的步骤:基线对比。
我见过太多团队,拿着一个精调过的RAG链路跑出90%准确率,心里很爽,但完全不知道这个90%意味着什么。没有对比,你根本不知道这90%里有多少得益于检索、多少得益于生成、多少是因为问题本身太简单。
我的做法是:同一个评估集,分别跑:
- 最简单的向量检索问答(不加任何rerank、prompt优化);
- 加了各种优化后的完整链路;
- 一个纯LLM直接回答(不检索)作为对照组。
然后比较三个实验的faithfulness、relevancy、correctness通过率。这一步做完,就能清楚地看到每一项优化到底带来了多少增益,也能判断哪项优化其实可有可无。
6. 把这两套机制沉淀进项目日常
6.1 结构化输出的后续使用建议
结构化输出的价值在于让下游程序“不再猜”。我在项目里落地时,一般会把抽取结果直接写入数据库,然后再加一个“字段来源可追溯”的设计:抽取出的结构化字段,同时保留一条原文索引。这样做的好处是,当业务方怀疑某个字段抽错了,可以直接跳到原文段落复核。
另外,结构化输出的retry逻辑一定要有上限。建议单次最多重试两次,超过就标记为“抽取失败”,交给人工兜底。千万别让系统无限重试,既浪费token,又可能在坏数据上来回打转。
6.2 评估结果怎么用起来
评估不是跑完一轮就结束的。我在项目里固定每周跑一次全量评估集,把三项指标通过率的变化画成趋势。谁动了prompt、谁换了embedding模型、谁加了新文档,都可能导致指标波动。没有这套持续评估机制,优化方向全靠拍脑袋,问题回归了也发现不了。
还有一个小技巧:把评估结果里的feedback字段收集起来,定期做一轮聚类。你会发现,大模型的“反馈”往往能指出共性问题,比如“回答引用了文档中未提及的年份”“上下文里缺少用户询问的具体字段”。这些反馈比单纯看通过率有用得多,它能直接告诉你下一步该优化检索还是优化生成。
6.3 这里有几个我自己的硬经验
最后分享几个踩过坑之后的总结。
第一,结构化输出的Pydantic字段description一定写清楚,这是成本最低、收益最高的改进点。很多“模型抽得不准”的问题,根源是字段描述模糊。
第二,评估器的模型选择要和线上模型区分开。线上用大模型保证效果,评估用小模型控制成本,这是可以接受的。但注意,小模型的判断不一定稳定,如果某个case在两次评估中结果不一致,不要惊慌,多跑几次取众数即可。
第三,我不建议把CorrectnessEvaluator作为唯一指标。它强依赖参考回答的质量,而参考回答本身是人写的,也有主观偏差。FaithfulnessEvaluator和RelevancyEvaluator更像“体检指标”,能从机制上发现系统性问题,数据和参考回答容易构造,普适性也更强。
我在实际项目里最深的一点体会是:结构化输出解决的是“机器能不能直接用”,评估解决的是“效果到底行不行”。前者让LLM从“答题者”变成“接口实现者”,后者让RAG系统从“感觉还可以”走向“有据可依”。如果能把这两件事揉进日常开发流程,RAG应用才真正有底气推到生产环境。