LlamaIndex结构化输出与评估实战:让模型按规矩说话并验证回答质量
2026/9/8 6:44:01 网站建设 项目流程

玩LlamaIndex有一段时间的人,迟早会撞上两个痛点:一是模型输出老是夹带私货,好好一个JSON里突然蹦出几句自然语言;二是系统上线之后根本说不清回答到底准不准,只能靠肉眼抽查。这两个痛点,正好对应LlamaIndex里最容易被忽视、却也最能拉开差距的两个模块——结构化输出与评估。这是系列第六篇,我把这两个东西放在一起讲,因为它们其实是一对配合:结构化输出解决“模型怎么按规矩说话”,评估解决“模型说得对不对、好不好”。如果你正在搭RAG问答、信息抽取或Agent工作流,这篇内容能帮你少踩很多坑。

先说结论:结构化输出的核心不是“让模型输出JSON”,而是“让模型在你定义的框架内做选择”;评估的核心也不是“给个分数”,而是“用可复现的规则发现失败case”。理解了这两点,后面所有代码和配置才有意义。

1. 结构化输出到底解决了什么问题

1.1 自由文本输出为什么“中看不中用”

把大模型当普通文本生成器用的时候,体验确实很爽,一段提示词下去,什么都能给你写出来。但一旦要接入业务系统,问题就冒出来了:你希望拿到一段JSON,模型偏要在一个字段后面补一句“以上是分析结果”;你希望分类结果只有“A/B/C”三种,模型给你输出“我认为这个属于B类,因为……”。这种不确定性,在demo阶段还能忍,上线后会让下游解析代码变成一场灾难。

我习惯把这个问题类比成让实习生填表格:你给他一张空表,告诉他“姓名、年龄、部门都填上”,他大概率会填,但偶尔会在备注栏写“这个客户很急”或者“今天心情不好”。你需要的不是批评他,而是把表格改成“每个格子只能填什么类型、长度多少、是否必填”的强约束表单。结构化输出就是这个道理:不是让模型更聪明,而是给模型一个不能越界的“表单”。

在LlamaIndex里,结构化输出通常指让LLM返回一个符合预先定义Schema的数据结构,最终得到一个Pydantic对象或JSON,供下游直接使用。常见的应用场景包括:从简历里抽取姓名、工作经历、技能标签;把用户问题转成查询结构化数据的参数;从评论里提取评分、摘要、情感倾向;让Agent决定下一步调用哪个工具、传什么参数。这些场景的共同特点是:输出格式错了,整个链路就断了。

1.2 三条实现路径,我的选型思考

LlamaIndex里做结构化输出,主流的做法有三条路,我分别说下优缺点和适用场景,方便你按自己的情况选。

路径实现方式优点痛点适合场景
提示词+手写解析在Prompt里写清楚JSON格式,再用json.loads或正则提取实现简单,不依赖额外依赖包模型偶尔输出markdown代码块、解释文字,解析极其脆弱快速验证、一次性脚本
函数调用/JSON Mode走OpenAI等厂商的function calling或response_format参数格式相对稳定,原生支持返回的是dict,缺少字段校验和类型约束,脏数据要靠自己洗对Schema要求不高、只做一次转换的场景
PydanticProgram用Pydantic类定义输出Schema,封装prompt与解析逻辑强类型、自动校验、可重试、可嵌套需要多写Pydantic模型,理解成本稍高正式项目、复杂业务Schema、要进生产环境

我的建议简单粗暴:凡是“输出要入库、要传给下游系统、要经过多轮校验”的场景,直接上PydanticProgram,不要省这点时间。凡是“跑一次看看效果”的场景,用提示词+手写解析就够了,不值得为一次性脚本引入额外复杂度。

1.3 写结构化输出前的四个预备动作

在动手写代码之前,先确认四件事,能帮你少走不少弯路。

第一,确认你的LLM支持function calling或JSON模式。如果用的是OpenAI较新的模型,基本没问题;如果是自托管开源模型,建议选支持工具调用的版本,或者干脆走“提示词+解析器”的路线。

第二,把temperature调低。结构化输出最忌讳随机性,temperature最好设置在0到0.1之间。我之前遇到过temperature默认1.0时,同一个输入来回两次输出字段名都不一致,把temperature降到0后问题立刻消失。这个细节很多人忽略,但它对稳定性的影响比任何提示词技巧都大。

第三,设计好输出Schema的“颗粒度”。字段不是越多越好,也不是越少越好。字段过少,模型会把多个信息塞进一个字符串里;字段过多,模型容易混淆相近字段。设计原则是:每个字段表达一个独立的语义单元,字段名一看就懂,description写清取值范围和判断标准。

第四,准备好“失败预案”。结构化输出再稳定,也有概率解析失败或校验失败。一定要预留重试逻辑,或者把失败记录落盘,人工介入修正。很多生产事故不是模型能力不够,而是没人处理那1%的解析异常。

2. 用PydanticProgram把输出“焊死”成对象

2.1 定义Schema:模型靠description理解需求

PydanticProgram的核心是Pydantic模型。你写一个类,每个字段代表输出中的一个键,字段的类型、默认值、描述共同构成了给模型的“填写说明”。关键在于:Pydantic的Field(description=...)不只是给你的代码看的,它会被拼进发给LLM的prompt里,是模型理解任务需求的主要信息来源。

所以description必须写清楚两件事:这个字段代表什么;取值应该遵循什么规则。比如rating字段,你可以写“电影评分,0到10之间的浮点数”,而不是只写“评分”。字段名也要直白,避免让模型去猜“label”到底指标签还是标题。

除了description,还可以用Field(fi=...)或者Field(examples=...)给出示例,效果非常明显。模型看到示例之后,理解成本会大幅下降。如果你的Schema有枚举值,更推荐用Literal["正面","负面","中性"]这种类型,直接从类型层面把取值锁定住,比跑了校验再去拒绝要省事得多。

2.2 一个能直接抄的完整示例

下面用影评抽取做例子,展示从定义Schema到调用的完整流程。这个例子的业务逻辑是:给模型一段影评文字,让它抽取电影名、评分、核心观点摘要、情感标签。

from typing import Literal from pydantic import BaseModel, Field, field_validator from llama_index.program import OpenAIPydanticProgram class MovieReview(BaseModel): title: str = Field(..., description="电影名称,保持原文语言") rating: float = Field(..., description="评分,0到10之间的浮点数,保留一位小数") summary: str = Field(..., description="影评核心观点摘要,不超过60个汉字") sentiment: Literal["正面", "负面", "中性"] = Field( description="影评整体情感倾向,只能从正面、负面、中性中选择一个" ) tags: list[str] = Field( default_factory=list, description="从影评中提取3到5个关键词标签,每个词不超过6个汉字" ) @field_validator("rating") @classmethod def rating_in_range(cls, v): if not 0 <= v <= 10: raise ValueError("rating must be between 0 and 10") return v program = OpenAIPydanticProgram.from_defaults( output_cls=MovieReview, prompt_template_str=( "你是一个专业的影视评论分析助手。\n" "请阅读以下影评,并严格按照给定格式抽取信息:\n" "{text}\n" ), verbose=True, ) text = ( "《流浪地球2》让我重新燃起对国产科幻的信心。" "特效场面宏大,但最打动我的是人类面对危机时的集体抉择。" "豆瓣虽然有一些争议,但我认为它至少值8分。" ) result = program(text=text) print(result) print(result.rating, result.sentiment, result.tags)

这里有几个细节值得说明。OpenAIPydanticProgram.from_defaults会自动把你的pydantic模型转成输出格式要求,并在拿到模型回复后做解析和校验。如果校验失败,支持自动重试,前提是你配置好重试参数(不同版本略有差异,建议看一眼你当前版本的from_defaults支持哪些参数)。field_validator是Pydantic v2的写法,如果你的项目还在用Pydantic v1,需要换成@validator,这点很容易踩版本坑。

2.3 批量处理与失败重试的工程化写法

单个调用好写,工程上真正麻烦的是批量场景。假设你有一万条影评要抽取,不可能一条条人工盯着,需要一套能让程序自己“跑完”的流程。我的做法是三步:分批处理、失败隔离、落盘检查。

import json from typing import Optional from pydantic import ValidationError def extract_review(text: str, program) -> Optional[MovieReview]: for attempt in range(3): try: return program(text=text) except (ValidationError, ValueError) as e: print(f"第{attempt + 1}次尝试失败: {e}") return None results = [] for i, raw_text in enumerate(all_texts): review = extract_review(raw_text, program) if review is not None: results.append(review.model_dump()) else: results.append({"index": i, "error": "parse_failed"}) with open("output.json", "w", encoding="utf-8") as f: json.dump(results, f, ensure_ascii=False, indent=2)

这段代码的思路是:把单条抽取封装成可重试函数,超过重试次数就记为失败,不让单个坏数据卡死整个批次。输出统一落盘,方便事后检查哪几条失败、失败原因是什么。生产环境我还会加一步:失败数据单独存一份,随后用规则或人工方式补采,而不是简单丢弃。实测下来,这种“隔离失败”的方式比“遇到一条错就停整个任务”效率高得多。

2.4 实战中踩过的坑

结构化输出用久了,各种翻车方式基本都见过,说几个印象比较深的。

第一个坑,模型返回合法JSON但字段名不对。比如你定义sentiment,模型给你返回sentiment_score,或者字段名缩写成了sent。Pydantic默认忽略多余字段,但缺少必填字段时依然会报错。解决办法是在description里“点名道姓”,甚至直接写“必须使用字段名sentiment”。

第二个坑,中文内容被转义或截断。特别是一些老模型对中文长度敏感,摘要字段明明要求60字,它可能输出60个token但只有30个汉字。写入数据库时容易被截断。建议在validator里加上长度上限,并在description里写“按汉字字数计,不超过60个汉字”。

第三个坑,嵌套结构难解析。比如输出里有子对象、对象列表,一旦某个子字段校验失败,整体解析就挂了。对策是尽量把嵌套层级控制在两层以内,子对象单独定义pydantic类,并给每个子字段写清楚description。

第四个坑是成本。结构化输出的重试机制会带来额外token消耗,尤其在批量抽取场景。我一般会在重试前做一次“检查失败类型”的拦截:如果是因为字段缺失,重试一次;如果是因为内容本身无法分类,直接放弃,避免无意义重试。这个判断可以靠错误信息里的关键字来做简单分类。

3. 评估模块:让模型回答变得可丈量

3.1 先想清楚:你要评估哪个环节

RAG系统上线之后,最怕的问题是“答案看起来很合理,但和文档内容对不上”。传统测试方法基本束手无策,因为答案不是确定的键值对,而是一段自然语言。LlamaIndex的评估模块思路是:用一套评估器对“查询-回答-参考上下文”这三元组打分,把主观感受转成可重复的数值。

这里必须强调一个概念:评估之前,先想清楚你要衡量哪一个环节。RAG链路大体分三段,每段失败的症状不一样:

  • 检索阶段:该检索到的文档没检索到。症状是回答内容挺好,但引用的上下文完全不对题。
  • 生成阶段:检索到了正确的上下文,但LLM没按上下文回答,自己编了一段。
  • 回答质量:上下文和回答都对,但是没回答用户的真实意图。

LlamaIndex为这三类问题分别设计了评估器。你用错评估器,就像用体重秤量身高,数据再准也说明不了问题。后面我会给对照表,这是评估环节最重要的一份地图。

3.2 LlamaIndex内置评估器速查表

先看一份我用下来觉得最常用的评估器清单:

评估器一句话说明核心问题使用场景
CorrectnessEvaluator回答与标准答案的匹配程度回答是否和参考答案一致有标准答案的测试集,评估整体回答质量
FaithfulnessEvaluator回答是否忠于上下文回答是否由提供的上下文支撑,有没有编造RAG生成环节,检验幻觉
RelevancyEvaluator回答与问题的相关程度回答是否在真正回答用户的问题RAG全链路,检验答非所问
SemanticSimilarityEvaluator回答与标准答案的语义相似度语义是否接近,而不只靠字面生成质量评估,对措辞变化不敏感
GuidelineEvaluator回答是否符合规则是否存在违规定、语气、格式问题有明确业务规范的场景
PairwiseEvaluator两个回答谁更好在同查询下比较不同回答优劣模型选型、Prompt调优对比

每个评估器内部的打分逻辑都是“让一个LLM扮演裁判”,给输出打1到5分(部分评估器用布尔值)并附上判断理由。所以实际运行时,评估也需要消耗token,跟生成回答的成本量级相同。记住这一点,后面控制成本的部分会用它来说事。

3.3 几个评估器背后的“评分逻辑”

光知道评估器名字不够,还得理解它内部怎么打分,才知道结果怎么解读。

FaithfulnessEvaluator的做法是:拿到回答里提到的若干个关键信息点,逐个去上下文里找依据。如果某个信息点找不到依据,就认为“不忠实”,整个回答可能被判为不通过。所以你看到Faithfulness分数低,第一反应应该是“回答里有上下文没有的内容”,而不是“回答质量差”。

RelevancyEvaluator的做法相反:它把“问题”作为核对项,去回答里找答案。很多时候回答长篇大论,但回答的是另一个问题,这种case用Relevancy才能抓出来。实测里,最常见的问题是用户问“费用是多少”,模型回答了一堆“如何申请”,相关度自然很低。

CorrectnessEvaluator更像是“阅卷老师”,它拿标准答案去比对模型回答,根据语义是否一致打1到5分。它对措辞不敏感,但对信息完整性敏感。如果你的测试集有标准答案,这个评估器是快速验证迭代效果的利器。

明白评分逻辑之后,你就知道为什么不能只盯着一个评估器看。我一般最少同时跑两个:一个管“有没有瞎编“(Faithfulness),一个管“有没有答非所问”(Relevancy)。两者都过了,再谈回答质量分。

4. 实操:搭一条评估流水线

4.1 评估数据集从哪来

评估做得再好,没有数据就是空中楼阁。我接触过的项目里,评估数据集来源有三种,成本递增、质量也递增。

第一,手写黄金样本。针对业务核心场景,人工写几十条查询和参考答案。这个门槛最低,适合起步,但覆盖度有限,很难把边角case暴露出来。

第二,用生成器构建。LlamaIndex提供了DatasetGenerator,可以从你的文档集合里自动生成“问题-上下文”对。它会把文本切分为节点,针对每个节点让LLM生成若干问题。这种方式速度快,能覆盖全量文档,但问题质量波动大,有些问题太简单,有些问题问得很别扭。建议生成后人工抽检一遍,把明显不合理的问题删掉。

第三,线上日志回流。把真实用户问题记录下来,配上人工或半自动的回答标注。这是最接近线上分布的评估集,质量最高,但需要业务支持,不是每个项目都有条件做。

我的建议是分步走:项目起步用手写黄金样本,验证链路能跑通;中期用DatasetGenerator扩充规模;上线前加上日志回流,形成可持续更新的评估集。不要盲目追求数据集大小,八十条高质量样本比八百条生成样本更能反映问题。

4.2 一键评估脚本

下面是我自己项目里用的一个评估脚本模板,逻辑是:读入一批测试样本,每条包含queryreference,然后先用你的RAG系统生成responsecontexts,再调用多个评估器打分,最后汇总输出。

import asyncio import pandas as pd from llama_index.evaluation import ( CorrectnessEvaluator, FaithfulnessEvaluator, RelevancyEvaluator, ) from llama_index.core import ServiceContext from llama_index.llms.openai import OpenAI eval_llm = OpenAI(model="gpt-4o", temperature=0) service_context = ServiceContext.from_defaults(llm=eval_llm) correctness = CorrectnessEvaluator(service_context=service_context) faithfulness = FaithfulnessEvaluator(service_context=service_context) relevancy = RelevancyEvaluator(service_context=service_context) async def evaluate_one(query: str, reference: str, query_engine): response = query_engine.query(query) contexts = [n.node.get_content() for n in response.source_nodes] c_result = await correctness.aevaluate( response=str(response), reference=reference, query=query ) f_result = await faithfulness.aevaluate( response=str(response), contexts=contexts ) r_result = await relevancy.aevaluate( response=str(response), query=query ) return { "query": query, "response": str(response), "correctness_score": c_result.score, "correctness_passing": c_result.passing, "faithfulness_score": f_result.score, "faithfulness_passing": f_result.passing, "relevancy_score": r_result.score, "relevancy_passing": r_result.passing, } async def run_evaluation(testset, query_engine): rows = [] for item in testset: row = await evaluate_one(item["query"], item["reference"], query_engine) rows.append(row) return pd.DataFrame(rows) # testset = [{"query": "...", "reference": "..."}] # df = await run_evaluation(testset, query_engine) # df.to_csv("eval_result.csv", index=False, encoding="utf-8-sig")

这套代码有几个值得注意的点。第一,LLM用temperature=0,这非常关键,让评估裁判的输出尽可能稳定。第二,aevaluate是异步接口,批量跑的时候速度会快很多。第三,结果我直接存成CSV,用Excel就能打开,方便给团队其他成员检查。

4.3 结果解读与badcase定位

评估跑完不是终点,解读才是重点。我拿到结果表之后,一般按下面三步走。

第一步,看通过率。比如Faithfulness通过率低于80%,意味着每五次回答里就有一次“上下文支撑不足”,这个比例在正式环境是不能接受的,需要回头查检索、查Prompt。

第二步,按分数分布看严重程度。同样是通过了,分数4和分数5差距很大。我一般把分数低于4的case单独捞出来,逐条看评估器的feedback,里面通常会写明“哪个信息点在上下文中找不到依据”或“回答没有覆盖参考中的哪个要点”。

第三步,把失败case按业务模块归类。比如“所有和退款相关的问题都答不好”,这往往不是模型问题,而是退款文档缺失或检索不到。这个时候就该调整索引结构或补充文档,而不是去改Prompt。

很多团队把评估做成“跑分机器”,跑完就结束,这是最浪费的用法。评估真正的产出是badcase清单,它是你迭代RAG系统的路线图。

5. 常见问题与避坑经验

5.1 评估分数忽高忽低

最让人头疼的,是同一个系统连续评估两次,分数波动很大。多半原因有三个。第一,评估用的LLM没设temperature=0,裁判自己都不稳定。第二,评估样本量太少,几十条数据里一两条异常case就能把平均分拉低很多。第三,评估集和线上分布差异大,评估集里集中了大量难题,导致分数整体偏低。

对策也很直接:评估LLM固定用低temperature;样本量尽量不低于50条;评估集要混合简单和困难样本,并在报告里分难度统计,而不是只给一个总分。我还会在同一批数据上跑两次评估,如果两次结果差异超过10%,先怀疑评估器稳定性,再去怀疑系统改动。

5.2 结构化输出解析失败的排查清单

解析失败的时候,我按下面这个清单排查,命中率很高:

先看原始返回内容。用verbose=True打印模型原始回复,确认是格式问题还是内容问题。再看Schema字段名和description,确认模型是否可能产生歧义。然后检查是否用了枚举或正则限制,能用Literal就不要让模型自由发挥。最后看校验器本身是不是太严格,比如rating要求“保留一位小数”,模型输出了整数8,validator是否应该兼容整数。

这里有个心态问题:解析失败不一定是模型不行,很可能是你的Schema设计让模型“不知道该填什么”。如果多轮排查后依然频繁失败,优先简化Schema,比如把5个字段减到3个,或者把嵌套结构拍平,效果往往立竿见影。

5.3 控制成本的三个思路

评估和结构化输出都费token,钱的问题绕不开。我的经验是三条。

一是控制重试次数,重试成本可能是成功调用的2到3倍,设置合理上限比无限重试更划算。二是评估集分级,日常开发用一个小而精的评估集快速跑,发布前再跑全量评估集,而不是每次改动都全量跑。三是用便宜模型做初步过滤,让大模型只处理被筛出来的高风险case,比如GuidelineEvaluator可以先跑一遍便宜的过滤模型,怀疑违规再让强模型做最终判决。

5.4 用评估结果反哺结构化输出

最后分享一个可以把两个模块串起来的技巧:把评估出的badcase当成结构化输出的“补充示例”。比如某类查询下,模型经常在sentiment字段里输出“偏正面”这种不在枚举里的值。你把这个失败case整理成一个few-shot示例,塞进PydanticProgram的prompt模板里,或者在输出Schema的描述里加一句“只能输出正面、负面、中性三个词,不要加修饰语”。我实际测试中,这种反哺方式比单纯调temperature更管用,因为它直接给模型看了“错误示范”。

另外,如果你把结构化输出和评估器结合,还能做自动化的质量门禁:解析成功的记录进入业务逻辑,解析失败但评估分数尚可的记录进入人工复核池,两边都失败的直接告警。这套机制跑起来之后,系统的稳定性会有一个质的提升。

我个人在实际操作中的体会是:结构化输出和评估,都不是“加了就完事”的功能,而是需要持续迭代的工程实践。每次拿到新的badcase,都值得回头想一想,是Schema设计的问题,还是评估集覆盖的问题,又或者是底层模型能力的问题。把这两个环节维护好,你的LlamaIndex应用才真正算得上“能上线”。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询