1. 为什么不能相信 LLM 的“自由发挥”
1.1 一个让人血压升高的 JSON 输出
我最近在做一个小型智能客服项目,需求本身不复杂:让大模型读一段中文采购记录,把关键字段抽成 JSON 交给后端入库。最开始我天真地以为,只要在提示词里把字段说清楚,模型就会老老实实按格式输出。结果上线第一天就翻车了。输入是“2024年3月15日,公司从京东购买A4纸一箱,金额120元,发票号12345678”,模型返回了这样的东西:
{ 商品名称: "A4纸", 金额: "120元", 日期: "2024年3月" }这个输出看着像 JSON,实际完全不能用。键名没有双引号,json.loads 直接抛错;金额带着中文单位“元”,后端如果按数值类型入库,必定类型不匹配;日期被截成了“2024年3月”,少了具体日;发票号字段直接失踪。更离谱的是,有些输出会在 JSON 外面套一层 Markdown 代码块,或者前面加一句“好的,我为你提取了以下信息”。这种输出对人和大模型对话没影响,但对程序来说就是彻头彻尾的垃圾数据。也就是从那一刻起,我意识到“约束 LLM 输出”这件事,不是写给模型看的规则问题,而是整个 LLM 应用工程里绕不开的核心环节。
1.2 约束输出的三种基本手段
做过几个项目之后,我把它总结成三条路线。
Prompt 规则,是最直观的思路,在提示词里明确写出目标格式、字段类型、取值范围、缺失值处理规则,让模型在生成时尽量往这些模式上靠。它属于“事前引导”,成本最低,改一遍提示词就完了,但效果完全依赖模型当下的状态。
代码校验,是在模型输出之后用正则、JSON 解析、schema 校验、重试等手段把不符合要求的输出拦下来或者修复掉。它属于“事后兜底”,不关心模型是否理解规则,只关心最终拿到手里的数据能不能用。代价是需要写代码,而且在某些场景下会把正确率“洗”成假正确。
few-shot 示范,是给模型几个输入输出对的标准示例,引导模型模仿示例的结构。它属于“先验引导”,比 Prompt 规则更符合 LLM 的生成机制——因为模型本质上是在预测下一个 token 的概率分布,给它看几个高标准的样本,比跟它说一百遍“你必须这样做”都管用。
这三种方法并不是互斥关系,现实项目里通常会把它们叠在同一条 pipeline 里。下面这四轮实测,就是围绕这三条路线以及最终组合方案展开的,所有结论都来自我在同一个任务上的真实测试数据。
2. 实测准备:任务、模型与评分口径
2.1 一个足够真实的抽取任务
为了让四轮对比有可比性,我设计了一个信息抽取任务:从一段中文采购文本中提取五个字段,强制要求以合法 JSON 返回。字段定义如下:
| 字段名 | 类型要求 | 额外约束 |
|---|---|---|
| trade_date | 字符串 | 格式必须为 YYYY-MM-DD,缺失不补 |
| amount | 浮点数 | 单位元,不能带货币符号或其他单位 |
| category | 字符串 | 只能取“办公用品”“打印耗材”“快递物流”三个枚举值 |
| product_name | 字符串 | 明文商品名称,不能省略 |
| invoice_id | 字符串或 null | 没有发票号时填 null,不能填空字符串 |
这个任务看着小,但足够折磨人。日期补零、金额类型、枚举归一、缺失字段处理,每一项都是 LLM 输出里高频翻车的点。太简单的任务体现不出约束手段的差异,太复杂的任务又不好定位问题源头。采购单抽取刚好卡在中间。
2.2 模型与参数设置
测试用的模型是某主流大模型的通用 API,调用方式和 OpenAI 兼容协议一致,方面复现。我把 temperature 设置为 0.3,max_tokens 设为 512,没有开流式输出。为什么是 0.3?温度越低,生成分布越收敛,逻辑类、格式类任务应该更稳。但我没有直接设 0,是因为温度归零在部分模型上会导致过强的重复倾向,尤其在长文本里会出现同一个词反复循环。0.3 是我在格式稳定性与内容多样性之间找到的平衡点。每组测试都用 100 条真实采购文本,覆盖不同日期、金额量级、商品类别和有无发票号的情况。
2.3 评分指标
对比不能只靠肉眼感觉,我固定了四个量化指标。格式合法率,指输出能被 json.loads 解析且通过字段 schema 校验的占比;字段准确率,指人工标注完全一致的字段占总字段的比例;重试率,指因为校验失败或解析失败而重新发起请求的次数占总请求数的比例;平均耗时,指从发出请求到拿到最终可用结果的总时长,包括重试时间。这四个指标分别回答一个问题:格式稳不稳、内容准不准、额外成本高不高、性能能不能接受。下面每一轮的数据,都按这四个口径统计。
3. 第一轮实测:只靠 Prompt 规则,效果能有多好
3.1 规则版提示词怎么写
第一轮我把所有希望都押在提示词上。system 消息写得很细:
你是一个结构化信息抽取器。请从用户提供的文本中提取以下字段并以合法 JSON 返回: - trade_date:交易日期,格式 YYYY-MM-DD - amount:金额,纯数字 float,单位元,保留两位小数 - category:类别,只能取以下三个值之一:办公用品、打印耗材、快递物流 - product_name:商品名称 - invoice_id:发票号,若输入中没有则填 null 要求: 1. 只输出 JSON,不要输出任何解释文字。 2. 不要使用 Markdown 代码块。 3. 字段名严格使用上述英文名。这段提示词里我自认为写清了所有关键信息:字段名、类型、枚举、缺失值、格式禁忌。为了让规则更醒目,我还在 user 消息末尾重复了一遍“请首先检查输出是否满足所有字段规则,然后直接输出 JSON”。当时我觉得这已经很稳了。但测试结果很快给我泼了盆冷水。
3.2 实测结果与翻车点
100 条样本跑下来,格式合法率只有 85%,字段准确率只有 72%。没有重试机制,所以重试率记为 0%,但代价是剩下 15% 的输出直接被判定为无效。翻车点非常集中。
第一条,模型在输出的 JSON 外面套了 Markdown 代码块。虽然规则里写了“不要使用 Markdown 代码块”,但这句规则显然没有压过模型训练数据里“用代码块展示代码”的通用倾向。第二条,金额字段经常变成字符串,比如"120.00元",让它输出 float,它给出一段带中文单位的字符串。第三条,日期格式混乱,2024-3-15、2024年3月15日都有,没有按规则补零。第四条,枚举值被改写,把“打印耗材”写成“耗材”,把“办公用品”写成“office supplies”。最让我无奈的是,有两条输出在 JSON 前加了“好的,以下是我抽取的结果:”这种前缀。
为什么会这样?核心原因在于 LLM 的生成机制。它本质上是逐 token 按概率采样,前面写过的规则在后续长序列中会逐渐被稀释。你可以想象一个记忆力一般的人被委托办事,你告诉他三条规矩,他前几分钟记得,办着办着就按自己的习惯来了。Prompt 规则能降低混乱程度,但要说保证合规,远远不够。
4. 第二轮实测:代码校验兜底,合法率拉到 100%
4.1 校验修复与重试的整体思路
第二轮我在模型输出与业务系统之间加了一层代码校验。思路分四步:清洗、解析、校验、修复/重试。
清洗,是把 LLM 输出里的噪音去掉,比如 Markdown 代码块、前后缀文本。解析,是尝试用 json.loads 把清洗后的内容变成 Python 对象。校验,是用 pydantic 定义模型类,对字段类型、格式、枚举做强约束。修复重试,是如果解析或校验失败,先尝试对部分已知脏格式做归一化修复,修复不动就重新调用模型。
下面是这个流程里核心代码的简化版:
import json import re from typing import Optional from pydantic import BaseModel, ValidationError class PurchaseRecord(BaseModel): trade_date: str amount: float category: str product_name: str invoice_id: Optional[str] = None def clean_llm_output(raw: str) -> str: # 去掉 Markdown 代码块标记 raw = re.sub(r"```json|```", "", raw).strip() # 找到第一个 { 和最后一个 },截取疑似 JSON 的区域 start = raw.find("{") end = raw.rfind("}") if start == -1 or end == -1: raise ValueError("no json object found") return raw[start:end + 1] def parse_and_fix(raw: str) -> PurchaseRecord: cleaned = clean_llm_output(raw) data = json.loads(cleaned) # 修复:金额字段可能是 "120.00元" 这类字符串 if isinstance(data.get("amount"), str): data["amount"] = float(re.sub(r"[^\d.]", "", data["amount"])) # 修复:日期可能不是补零格式 if re.fullmatch(r"\d{4}-\d{1,2}-\d{1,2}", data.get("trade_date", "")): y, m, d = data["trade_date"].split("-") data["trade_date"] = f"{y}-{int(m):02d}-{int(d):02d}" return PurchaseRecord(**data)为什么用 pydantic 而不是裸的 dict 判断?因为 json.loads 只能保证 JSON 语法合法,不能保证类型合法。比如 amount 是字符串,json.loads 照样成功,但 pydantic 会当场拒绝。还比如 category 不在枚举里,也需要在 PurchaseRecord 之外加断言。把校验定义在 schema 层,后续扩展字段、接入不同业务场景时,只需要改模型类,不用动整个调用链。
4.2 代码校验的新问题
加了这层校验之后,最终格式合法率确实到了 100%,因为不合格的输出要么被修复,要么被重试,最后返回的一定是能通过 schema 校验的结果。但代价非常明显:重试率达到 21%,平均耗时从 1.2 秒涨到 1.8 秒,而字段准确率只从 72% 涨到 78%。
有一个问题是我一开始没料到的:强行修复会产生“假正确”。比如模型把 category 写成“耗材”,归一化逻辑直接把“耗材”映射成“打印耗材”,但真实业务里“耗材”可能指办公耗材,也可能指打印耗材,一旦修复逻辑猜错,校验通过了,内容却是错的。再比如金额字段“120元”,清理成“120”,如果业务上金额应该是“120.00”,pydantic 会当成 120.0,看着对了,但序列化给前端时又可能因为精度格式引发新问题。
更关键的是重试没带上错误上下文。我第一次实现里,校验失败后只是把整个 prompt 原样再发一次,完全没有追加“上一轮为什么失败,请如何修正”的信息。结果模型自然会把同样的错误再犯一遍,21% 的重试率里有一大半是白重试的。我意识到,代码校验只能解决“形状不对”,解决不了“内容不对”,更不可能把模型没有理解的规则通过重试灌输给它。它是一张安全网,不是一张质量网。
5. 第三轮实测:few-shot 示范,让模型照着抄
5.1 示例怎么放、怎么选
第三轮我暂时去掉代码校验,只靠 few-shot 示范来约束。方法很简单,在 user 消息里先放两三个标准输入输出对,再放真正需要抽取的目标文本。这里的关键是样例本身的质量,样例格式有一点不统一,模型就会放大这种不一致。
我选的第一个样例是:
输入文本:2023年7月25日,在京东购买标签纸2卷,金额45.50元,发票号238556。 输出:{"trade_date": "2023-07-25", "amount": 45.5, "category": "办公用品", "product_name": "标签纸", "invoice_id": "238556"}第二个样例是:
输入文本:2024-01-08 12:33,顺丰寄文件到上海,运费23,发票无。 输出:{"trade_date": "2024-01-08", "amount": 23.0, "category": "快递物流", "product_name": "顺丰快递", "invoice_id": null}注意我故意在样例里放了一个 invoice_id 为 null 的情况,让模型明白缺失字段应该输出 null,而不是跳过字段或填空字符串。两个样例的日期格式完全一致,金额一个是 45.5 一个是 23.0,但浮点都是数字类型。为了让模型覆盖到第三个枚举值“打印耗材”,我通常会在正式测试集里再补一个 category 为“打印耗材”的样例,或者靠多轮动态 few-shot 在推理时按输入类型选样例。
5.2 few-shot 的收益与隐患
实测效果比前两轮都好看。格式合法率 95%,字段准确率 83%,因为输出结构基本稳定,需要重试的比例降到 4%。尤其是金额和日期,模型不再随心所欲,它会从样例里提取“日期用横线加补零、金额用浮点数”这个隐性规律。这说明 few-shot 提供的不是“规则指令”,而是一个概率分布。模型在看到两个样例后,会倾向于复制样例的形态,这就是它比纯 Prompt 规则更接近 LLM 工作方式的原因。
但隐患同样明显。第一,模型会过度模仿样例里的具体值。比如样例里 product_name 是“标签纸”,当新输入是“购买A4纸”时,模型有概率把 product_name 也写成“标签纸”。这是因为模型在短上下文里抓取“相似位置”的能力很强,但不理解“这个位置应该填输入文本里真实出现的商品名”。第二,如果样例类别覆盖不全,模型会更倾向把不确定的内容归到样例中出现次数多的类别。第三个样例只涵盖了办公用品和快递物流,那么“打印耗材”的字段准确率就会偏低。第三,few-shot 样本不是越多越好,这一条我在后面常见问题里展开细说。
总的来说,few-shot 是三种单点方案里性价比最高的一种,它真正利用了 LLM 的生成机制,让模型“照着抄”而不是“听指令”。但要让“照着抄”不抄错,需要仔细设计样本多样性和结构一致性。
6. 第四轮实测:三者组合,最终怎么收敛
6.1 三层递进的组合策略
第四轮我把三种手段放在同一条 pipeline 里,不是简单叠加,而是按“事前引导、采样示范、事后兜底”三个层面递进组合。
第一层是系统提示词里的规则,保持第一轮的字段定义、类型约束、枚举要求。第二层是 few-shot 示例,作为 user 消息的一部分,放在待抽取文本之前。第三层是代码校验,输出先清洗、再解析、再走 pydantic,校验失败时把错误原因拼进下一轮请求,并要求模型严格按示例重新输出。
组合后的请求构造代码大概是这样的:
def build_prompt(rule_prompt, examples, target_text): user_content = f"请参考以下标准示例:\n{examples}\n\n目标文本:{target_text}" return [ {"role": "system", "content": rule_prompt}, {"role": "user", "content": user_content}, ] def call_with_retry(rule_prompt, examples, target_text, max_retries=2): for attempt in range(max_retries + 1): history = build_prompt(rule_prompt, examples, target_text) if attempt > 0: history.append({ "role": "user", "content": f"上一次输出未通过校验:{last_error}。请严格按示例格式重新输出。" }) resp = llm_call(history, temperature=0.3 if attempt == 0 else 0.1) try: return validate_and_fix(resp) except Exception as e: last_error = str(e) raise RuntimeError("exceeded retry limit")这套设计里,判断为什么失败靠的是把 last_error 回传给模型。之前第二轮失败后盲目重试,是因为模型根本不知道自己哪里惹怒了校验器。现在它拿到“category 字段值不在枚举列表中”或“trade_date 格式不是 YYYY-MM-DD”这句话,在下一次生成时会显著减少同类错误。这个技巧几乎适用于所有大模型 API,只要有对话补全能力,就能把校验器变成一个“批评者”。
6.2 最终效果与成本分析
组合方案在同样 100 条测试集上的表现是:格式合法率 100%,字段准确率 91%,重试率降到 12%。相比纯粹代码校验的 21% 重试率,降了一半;相比纯 few-shot 的 83% 字段准确率,提升了 8 个百分点。这个提升来源很明确:few-shot 完善了格式和枚举的先验,校验错误回传又让模型在下一轮知道该往哪个方向修正,Prompt 规则则把最后容易混淆的边界卡住。
成本方面,组合方案的 prompt 长度比纯规则增加了大约 350 个 token,主要来自两个 few-shot 示例。但重试减少后,平均单次调用总时长反而接近纯 few-shot,比纯代码校验低。在目前的大模型 API 计费体系下,提示词 token 价格通常低于输出 token,少一次重试比省几个输入 token 划算得多。真正值得思考的是这 91% 的字段准确率上限。剩下的 9% 错误,大多是产品名称抽取语义错误,比如把“A4纸”抽成“纸”,或者把“顺丰快递”抽成“快递服务”。这种问题不是格式约束能解决的,它属于语义理解层面,需要靠模型本身能力或者更高质量的抽取模板来提升。
这里多说一句,我在 RAG 知识库问答的场景里也用同样的套路,让模型返回结构化证据列表。只要下游需要程序消费 LLM 输出,就一定要在 Prompt 里给规则、给样例、外面再套校验。没有例外。
7. 四轮结果对比与方案选型
7.1 结果对比表
把四轮的数据放在一起看,规律很清楚:
| 方案 | 格式合法率 | 字段准确率 | 重试率 | 平均耗时 |
|---|---|---|---|---|
| 第一轮:纯 Prompt 规则 | 85% | 72% | 0% | 1.2s |
| 第二轮:代码校验 | 100% | 78% | 21% | 1.8s |
| 第三轮:few-shot 示范 | 95% | 83% | 4% | 1.3s |
| 第四轮:组合方案 | 100% | 91% | 12% | 1.5s |
需要注意,第二轮和第四轮格式合法率都是 100%,因为不满足 schema 的一律重试或修复,最后返回的数据必然通过校验。但这个 100% 不代表模型更聪明,只代表校验层把边界守住了。真正拉开差距的指标是字段准确率和重试率,这两个数字才反映模型输出质量的实际提升。
7.2 按场景选型建议
不同项目对“稳”和“准”的优先级不一样,选择也会不同。
如果你只是在内部脚本里跑一次,输出结果人工查看,纯 Prompt 规则加一段简单的 JSON 解析就够了,省时省力,偶尔格式错了自己改一下就好。如果你的输出要直接喂给后端、数据库、前端或者另一个 Agent,那么代码校验是底线,组合方案是推荐配置。只要一次非法输出造成了线上事故,省下的 token 成本远远补偿不了。
如果团队还在探索阶段,不确定字段规则是否完备,建议从 few-shot 示范起步。它追改成本低,模型效果上限高,也更容易暴露语义层面的问题。如果到了高并发阶段,尽量降低重试次数,宁可把重试上限从 2 次降到 1 次,也要把 few-shot 样例质量打磨好,让模型在第一次生成时就接近标准答案。另一个容易被忽略的点是字段准确率和业务规则绑定,校验器里写的枚举和格式如果有更新,必须同步改测试集,否则模型升级一次,校验逻辑就成了老黄历。
8. 常见问题与排查技巧实录
8.1 模型总是把 JSON 包在 Markdown 代码块里,怎么处理
这个问题在内部模型和开源模型上尤其常见。我见过不少同学在 prompt 里写“不要使用 Markdown 代码块”,结果模型依然执着地在 JSON 前后加json。最平滑的解决方案不是跟它较劲,而是用代码容忍这种情况。清洗时先把json 和 ``` 替换掉,再用正则找第一个{和最后一个}作为 JSON 边界。为什么取最后一个}?因为如果模型在 JSON 后面又输出了一段解释文本,那解释文本里大概率也包含括号,取最后一个}能最大化地保住 JSON 主体。当然这种做法也有翻车风险,比如输出里嵌套字典和列表,最后一个}可能是外层,也可能漏了尾部,所以我的经验是同时保留“找第一个{”和“找最后一个}”两个边界,然后让 json.loads 失败时再尝试逐层截断。
8.2 重试多次还是同一个错
如果校验失败后重试了好几轮,模型还在犯同样的错,不要急着加大模型能力或重写提示词,先检查这三件事。第一,重试请求里有没有把上一次的输出和失败原因发回去。大多数 API 调用是独立的,上一次失败和这一次生成没有任何关联,模型根本不知道你为什么不满意。第二,temperature 是否过高。我在组合方案里会把重试时的温度降到 0.1,就是让模型更倾向于重复已有的稳定路径,而不是重新发明一种新错误。第三,few-shot 样本里是否存在互斥结构。如果第一个示例日期是横线格式,第二个示例日期是斜杠格式,模型会认为两种都可以,于是随机选择,重试自然压不下去。解决方式是把样本统一成一种强格式,并让失败反馈里明确指向这个格式。
8.3 few-shot 样本越多效果反而越差
很多人以为示例越多越好,我实测发现超过某个阈值后效果会掉。原因是样例里的非关键细节,比如具体的商品名、公司名、金额数字,会被模型当成“高频 token”吸收,导致它在输出新字段时强行复用这些旧值。样例越多,噪声越大。所以我的原则是样本数控制在 2 到 4 个,覆盖所有关键枚举值和边界条件,而不是穷举。如果业务类别有十几个,不要把它们全塞进 prompt,用动态 few-shot,根据当前输入的某些特征从样本库里挑最相似的 2 个放到 prompt 里。这一步很多第三方 LLM 框架已经支持了,本质上和 RAG 检索的道理一样,只是检索单位从文档变成了输出样例。
8.4 校验规则和业务规则不同步
代码校验看起来很安全,但它的安全边界取决于 schema 写得多准。我最开始只用 pydantic 做类型校验,没有对枚举和正则做断言,结果模型把 category 输出成“办公用品类”也能通过校验,因为字段类型是 str,pydantic 不会拦。后来我意识到,校验规则必须完整复刻业务规则,并且要由业务方和维护 prompt 的人共同确认。比如金额是否允许为负、日期是否允许未来的日期、产品名称是否允许超长字符串,这些不是模型该决定的,而是代码校验该管的。校验器里每多一条断言,模型的输出行为就会多一层约束,但也要承担误杀的风险,所以每加一条规则都要配套跑一遍回归测试,确认召回不掉太多。
9. 我的几点实际体会
跑完这四轮,我最大的感受是:约束 LLM 输出不是选一个方法,而是建一套体系。Prompt 规则是让模型“知道标准”,few-shot 是让模型“看过标准”,代码校验是让模型“达不到标准就重来”。三者缺一不可。只看单一指标很容易被带偏,比如代码校验让格式合法率到了 100%,但字段准确率没上来,业务一样跑不起来;few-shot 让格式和准确率都有提升,但遇到模型没见过的新格式,照样会崩。
我在实际项目中,已经把这套组合方案做成了一个很薄的工具函数,所有调用 LLM 的地方都走同一个出口,不管是普通问答还是 RAG 知识库里的结构化输出,统一做清洗、校验、错误回传。后来发现,真正的坑不在 prompt 怎么写,而在校验规则跟业务规则之间的同步。每次业务字段调整,模型和校验器都要一起更新,否则模型升级一次,错误特征就换一层皮。
最后分享一个让我长期受益的小技巧:每次校验失败的原始输出不要直接扔掉,攒到错误库里。每周挑几条典型的、新出现的错误,更新到 few-shot 示例库里。这样模型会随着业务数据变化慢慢变好,“约束”就从静态规则变成了一套可以持续演化的模板资产,而不是改一次 prompt 就再也不碰的配置。希望这份四轮实测记录,能帮你在被 LLM 输出格式虐到的时候,少走一点弯路。