上周帮朋友看一个内部工具,他把“从合同文本里抽字段”的功能改到第三版,每加一个字段就要动三处:提示词里的一段字段说明、一个手写的 JSON Schema 字典、还有一个把返回值塞进 dataclass 的转换函数。三处只要有一处没对齐,线上就开始抛KeyError。我问他为什么不直接拿类型注解去驱动,他说“注解是写给人看的,模型又看不懂”。这其实是个挺普遍的误解:Python 类型注解恰好是人和模型都能读的一种中间语言,人读得懂字段含义,模型读得懂结构约束。
Magentic 这类框架干的事情说白了就一件——把类型声明当成唯一事实来源,自动翻译成结构化输出的约束和函数调用的描述,再把模型吐回来的东西校验、反序列化成你熟悉的 Python 对象。标题里那三个关键词:Python 类型注解、结构化输出、函数调用,其实是同一条流水线上的三个工位。这篇不打算复述框架文档,而是从“我自己会怎么写、哪里会翻车、翻车之后怎么查”的角度,把这条流水线拆开讲。适合已经把模型接进业务代码、但还在手写 schema 和转换函数的同学,也适合刚开始接触工具调用、被各种 JSON 解析异常折磨过的读者。
1. 类型注解凭什么能当 LLM 的“合同”用
1.1 一份类型声明,两个消费者
先说清楚为什么是类型注解,而不是 YAML 配置、不是表格、不是一堆散落在提示词里的字段说明。原因在于类型注解天生具备两种属性:运行时可以被反射出来,语义上又足够接近 JSON Schema。前者让程序能自动干活,后者让模型能理解。这两点同时成立的东西其实不多,正则不行,自然语言描述不行,手写的字典倒是可以,但它是重复的第二个事实来源。
举个最常见的例子:
from dataclasses import dataclass @dataclass class LineItem: sku: str qty: int unit_price: float = 0.0对人来说,这段代码一眼就能看出“商品编码是字符串、数量是整数、单价是浮点,不填默认零”。对框架来说,sku: str这行本身携带了字段名、类型、是否必填三层信息,unit_price: float = 0.0额外携带了默认值。这些信息拼起来,正好就是一份结构描述。你不需要再写一遍{"sku": {"type": "string"}, ...}。
我在实际项目里踩过的最深的坑,就是维护了两份描述。一份在提示词里,一份在代码里,某次提示词改了字段名,代码没改,模型老老实实按新名字输出,校验层按旧名字找,结果整批数据静默变成空值。这种 bug 不报错,只是数据变少,往往过了好几天才被发现。把描述收敛到一处之后,这类问题从根上消失了。
1.2 从注解到 schema 的翻译链路到底做了什么
很多人以为框架里有什么魔法,其实拆开看就是一次递归遍历。核心用到三个反射能力:typing.get_type_hints把注解解析成真实的类型对象(这一层专门处理from __future__ import annotations带来的字符串注解),typing.get_origin和typing.get_args拆解list[X]、Optional[X]这类泛型,dataclasses.fields或者模型的字段元信息拿到默认值和额外约束。
下面这段是我自己写过的简化版,三十行不到,能覆盖八成日常场景:
import typing import dataclasses PRIMITIVE = {str: "string", int: "integer", float: "number", bool: "boolean", type(None): "null"} def to_schema(tp, defs, seen=None): seen = seen or set() if tp in PRIMITIVE: return {"type": PRIMITIVE[tp]} origin = typing.get_origin(tp) args = typing.get_args(tp) if origin in (list, typing.List): return {"type": "array", "items": to_schema(args[0], defs, seen)} if origin in (dict, typing.Dict): return {"type": "object", "additionalProperties": to_schema(args[1], defs, seen)} if origin is typing.Union: # Optional[X] 就是 Union[X, None] branches = [a for a in args if a is not type(None)] node = to_schema(branches[0], defs, seen) if len(branches) == 1 else { "anyOf": [to_schema(a, defs, seen) for a in branches] } if type(None) in args and "type" in node: node = dict(node, type=[node["type"], "null"]) return node if origin is typing.Literal: return {"enum": list(args)} if dataclasses.is_dataclass(tp): name = tp.__name__ if name in seen: # 自引用保护 return {"$ref": f"#/$defs/{name}"} seen.add(name) props, required = {}, [] hints = typing.get_type_hints(tp) for f in dataclasses.fields(tp): props[f.name] = to_schema(hints[f.name], defs, seen) if f.default is dataclasses.MISSING and f.default_factory is dataclasses.MISSING: required.append(f.name) defs[name] = {"type": "object", "properties": props, "required": required} return {"$ref": f"#/$defs/{name}"} raise TypeError(f"暂不支持的类型: {tp!r}")调用的时候把defs作为外部字典传进去,最后拼成一份完整的 schema。你会注意到几个细节,这些细节恰好是后面所有坑的源头。
第一个细节:Optional[X]的处理。它同时影响type和required两处——类型上要允许null,必填列表里要去掉这个字段。很多框架只做了前一半,于是模型依然认为这个字段必须给值,只是可以给null,导致“不该出现的 null”比“字段缺失”还多。
第二个细节:default的处理。有默认值的字段不进required,这件事看起来天经地义,但如果你只写了类型注解没写默认值,模型就完全不知道这个字段可以不给。我在一个报表字段上吃过亏,二十多个可选维度全被模型塞了值,其中一半是瞎编的。
1.3 第一次跑通之后我看到的真实落差
第一次跑通那种兴奋感,很多人应该都记得:定义个 dataclass,丢给模型,几秒钟回来一个能直接**kwargs展开的对象,感觉以后再也不用写解析代码了。但跑了两天就发现,类型只保证了“形状”,完全不保证“语义”。
qty: int确实能拦住字符串,但它拦不住负数。sku: str能拦住数字,但拦不住空字符串。unit_price: float不区分单位是元还是分。status: str更是放飞,模型可以给你“已完成”“完成”“已经完成”“COMPLETED”四种写法。这些都不是类型系统能表达的,必须靠额外的约束和业务校验。
所以我在自己的项目里加了一条硬规矩:类型注解负责结构,Literal和Annotated负责枚举和格式约束,剩下的业务语义一律在拿到对象之后走一遍独立校验函数。这三层各管一段,别指望一层包打天下。理解了这个落差,你就不会对框架有不切实际的期待,也不会在校验失败的时候怀疑是框架的 bug。
2. 结构化输出的三段式:约束、校验、回填
2.1 生成层:约束到底该写在 schema 里还是提示词里
这是第一个需要做决策的地方。同一件事,你可以放进 schema,也可以写进提示词,效果差别很大。
我的经验是按“机器能不能判”来划分。机器能判的,比如类型、枚举、长度、正则、数值范围,全部塞进 schema 或者字段描述里,因为这类约束在解析环节能被程序直接检查,失败了还能拿到明确的错误信息。机器判不了的,比如“这个字段只在合同类型为采购时才需要填”“金额要换算成含税价”,写进提示词的自然语言说明里,配合几个正反例。
举个具体的:我做过一个发票信息抽取,invoice_type有六种取值。如果把六种取值写进提示词让模型“从六个里选一个”,第一版准确率大概八成多;改成Literal["增值税专用", "增值税普通", ...]放进 schema,schema 里直接出现enum字段,准确率立刻上去了。原因不复杂,枚举会作为约束参与解码,模型的输出空间被收窄了,选错的概率自然低。这个信息如果你只在提示词里说,模型得“记住”约束;放在 schema 里,约束是系统层面的。
反过来的例子:字段之间的依赖关系,schema 表达起来非常别扭,oneOf加条件分支能写但极其难调试,这种就别硬塞了,老老实实写提示词,再在拿到结果之后跑一遍交叉校验。
2.2 校验层:把校验当成开关还是当成信号
校验这件事,很多人只把它当开关用——过了就走,不过就抛异常。这么用当然没错,但浪费了它最大的价值:校验失败的信息本身就是一条极高质量的反馈,可以直接喂回给模型让它自己修。
我在代码里把失败分成两类,处理方式完全不同。
第一类是结构性失败,模型返回的根本不是合法 JSON,或者外面裹了代码块、前后带寒暄。这类失败不需要回填具体内容,直接把原始输出截断重试一次通常就好了,成本很低。
第二类是语义性失败,JSON 能解析,但字段不对。这类失败必须回填,而且回填的质量直接决定第二轮能不能救回来。
判断用哪一类,最简单的办法是先尝试解析,解析失败走第一类,解析成功再走模型校验,校验失败走第二类。伪代码大概是:
def structure(raw: str, model_cls, client, budget=3): msgs = [build_prompt(model_cls)] for attempt in range(budget): raw = client.complete(msgs) try: data = json.loads(strip_fence(raw)) except json.JSONDecodeError as e: msgs += [{"role": "assistant", "content": raw}, {"role": "user", "content": f"输出不是合法 JSON:{e.msg},位置 {e.pos}。请只输出 JSON。"}] continue try: return model_cls(**data) except ValidationError as e: msgs += [{"role": "assistant", "content": raw}, {"role": "user", "content": format_errors(e)}] raise RuntimeError("超过重试预算")这里的strip_fence是我单独抽出来的一个小函数,专门剥 ```json 包裹和首尾空白,看起来有点土,但它是我整个链路里调用次数最多的工具函数,没有之一。
2.3 回填层:错误信息怎么写模型才听得懂
这是我觉得最值得单独讲的一节,因为它决定了重试是“有效修复”还是“盲目再来一次”。
很多人直接把异常堆栈粘回给模型,动辄上百行,模型看完注意力全被带偏了。我试过几种写法,效果最好的是极简三要素:字段路径 + 期望 + 实际。比如:
以下字段不符合要求,请只修正这些字段,其余保持不变: - items[2].qty:期望整数,实际收到字符串 "3" - invoice_date:期望格式 YYYY-MM-DD,实际收到 "2024年3月5日" - status:期望取值之一 [draft, paid, void],实际收到 "已付"为什么这种写法效果好?因为它把修改范围框死了。如果你只说“你输出错了”,模型会重写整份 JSON,重写过程里原本正确的字段有可能被改坏,这在长文档抽取里特别常见。你把范围限定到两个字段,它大概率只动这两个。
还有一个技巧,回填的时候要把上一轮的输出一起带回去。只发错误信息不发原始输出,等于让模型从零重写;带上原始输出,它做的是一个局部修补动作,成功率明显更高。这件事在框架层通常会自动处理,但如果你是手写循环,一定要记得。
最后说重试次数。我给自己的默认预算是 3 次,超过就降级:要么返回一个标记了“部分成功”的对象,要么转人工。无限重试是最糟的选择,既烧钱又掩盖问题,而且实践下来第三轮之后成功率提升非常有限,模型陷入了同一个死循环。
3. 函数调用:把函数签名变成一份工具说明书
3.1 docstring 才是真正的提示词
结构化输出和函数调用在底层是同一件事:都是让模型按给定的结构产出数据。区别在于,结构化输出的对象是一份数据文档,函数调用的对象是一次动作,模型需要判断“什么时候该做这个动作”“参数该填什么”。
这里最容易被低估的,是 docstring。类型注解只告诉了模型参数长什么样,完全没告诉它什么时候该调用这个函数、参数该从哪里取。这些信息如果不在 docstring 里,模型就会瞎猜。
举个真实对比。一个查订单的函数,第一版我是这么写的:
def query_order(order_id: str) -> Order: """查询订单。"""结果模型在用户问“我上周买的那个东西到哪了”的时候,会直接编一个order_id出来,参数形如"ORD-20240305-001",格式看着挺像,实际根本不存在。后来我把 docstring 改成:
def query_order(order_id: str) -> Order: """按订单号查询订单详情(物流、金额、状态)。 仅在用户明确提供了订单号时调用。若用户只描述了商品或时间, 没有给出订单号,不要调用本函数,先向用户索要订单号。 """问题立刻消失了。差别就来自那句“仅在用户明确提供了订单号时调用”。这句话在提示词工程里叫“调用条件”,在函数签名里没有它的位置,只能写在 docstring 里。
我现在的习惯是,每个工具函数的 docstring 都强制包含三块内容:这个工具做什么、什么时候该调、什么时候不该调。缺任何一块我都认为这个工具没写完。第二块和第三块其实比第一块重要得多,因为模型判断“要不要调”比判断“怎么填参数”更容易出错。
3.2 参数类型决定了模型敢不敢填,也决定了它怎么填
参数上的类型选择,直接影响模型的行为倾向。这几点是我观察下来比较稳的规律。
str是万能的,也是最危险的。如果你把日期写成date: str,模型会用各种你能想到和想不到的格式填它,因为str在语义上不排除任何东西。换成datetime.date或者用Annotated[str, Field(pattern=...)],输出立刻规矩。类型越具体,模型的自由度越小,你后面需要的清洗代码就越少。
bool有个特殊问题。模型经常把“不确定”表达成false,因为布尔只有两个值,没地方放第三种状态。凡是存在“未知”可能的字段,我一律写成Optional[bool],并且在描述里明确“无法判断时填 null”。这一点在字段抽取场景里特别重要,否则所有的“不知道”都会变成明确的“否”,数据看起来很干净,实际上是错的。
列表参数要给明确的元素类型。tags: list这种写法等于什么都没说,模型可能给你字符串,也可能给你对象。写成list[str]或者list[Tag],行为就完全不一样了。另外列表还有个隐性约束:数量。模型有时候会为了“显得完整”塞十几条元素进去,如果你只想要前三条,最好在描述里写清楚上限,或者在 schema 里加maxItems。
3.3 工具返回值怎么回到对话里
这一步框架通常帮你做了,但值得知道它背后的逻辑,因为出问题的时候你得知道去哪儿查。
模型发起一次工具调用,本质上是提出了一个“我需要这个数据”的请求,然后停下来等你。你的程序执行完函数,得到一个返回值,这个返回值需要被序列化成文本再塞回对话,模型才能继续。这里有两个常见的坑。
第一个是序列化的信息量。如果你把整个 ORM 对象str()一下扔回去,模型拿到的可能是一堆内存地址和无意义的表示。返回值要么转成 dict 再json.dumps,要么在 schema 层做一次裁剪,只回传模型真正需要的字段。我在一个例子里见过把包含几万字符的列表原样回传,把上下文撑爆的情况。
第二个是失败怎么表达。工具执行抛异常了,你是把异常文本扔回去,还是包装成一个结构化的错误对象?我的做法是统一包成{"ok": false, "error": "...", "hint": "..."}回传,其中hint是给人看的补充说明,告诉模型下一步该怎么办,比如“订单号不存在,请向用户确认是否正确”。直接扔原始堆栈的话,模型经常会对着堆栈文本胡乱推测,甚至把堆栈里的内部标识符当成答案返回给用户。这个细节在演示阶段完全看不出来,上了生产会被用户投诉得很惨。
4. 类型系统里那些好看但会炸的写法
4.1 Optional 和 Union 造成的空值歧义
Optional[X]表面上只是“可以是 X 也可以是 None”,但它会同时改动 schema 的三处地方:类型允许 null、字段不进 required、模型对“要不要填”的犹豫度上升。这三件事叠在一起,很容易产生大量无意义的 null。
我的处理原则比较粗暴:如果一个字段 90% 的情况下都有值,就别用Optional,改成必填 + 提示词说明“确实无法获取时填空字符串”。反过来,如果这个字段本来就大部分情况没有值,那就大胆用Optional,但一定在描述里写清楚“没有时填 null,不要编造”。
真正麻烦的是多分支的Union,比如Union[Invoice, Receipt, Contract]。JSON Schema 里会用anyOf表达,模型看到一个anyOf会怎么想?它经常把三个分支的字段混着填,产出一个哪个都不匹配的四不像。我的经验是把这类联合类型彻底拆开:要么先让模型做一次分类,拿到类型之后再走对应的 schema;要么加一个显式的type判别字段,用Literal约束住,让结构从“多选一”变成“带标签的联合”。后者是业界常见的做法,可调试性高很多。
这种情况还有一个隐藏成本:anyOf的 schema 体积是各分支之和,token 消耗直接涨上去,而且分支越多,模型注意力越分散。三个以上分支的联合类型,我基本都会拆成两步调用。
4.2 嵌套层级和 token 预算的隐性冲突
嵌套模型很好看,Order里有list[LineItem],LineItem里有Product,Product里有list[Attribute]。写起来清爽,但每一层嵌套都会在 schema 里展开一次,如果是内联展开而不是$ref引用,重复的字段描述会成倍复制。
我做过一次实测,一个四层嵌套的订单模型,完整 schema 大概是 3600 个 token。这个数字放在单次调用里不算什么,但你要考虑三件事:第一,它占据了上下文窗口,尤其在长文档抽取时,输入本身就几万字;第二,它是每次调用都要付的钱,一天跑十万次就是三亿多 token 的纯 schema 开销;第三,也是最容易被忽略的,schema 太大之后模型的“注意力”会被稀释,字段填错率上升,这个现象在我自己的测试里是能复现的。
应对办法有几种。比较直接的是按场景裁剪:抽订单详情和抽订单摘要用两个不同的模型,别用一个巨无霸模型通吃。第二种是把深层嵌套拍平,用扁平结构加 ID 引用的方式表达,模型填起来更稳。第三种是分步抽取,先抽主表再按主表的 ID 抽明细,虽然多一次调用,但每次的 schema 都很小,总体成功率反而更高。这几种我都在不同项目里用过,选哪个主要看调用量和字段数量的比例。
4.3 Literal、Enum、Annotated 三者的约束强度不一样
这三个东西在“让模型别乱填”这件事上的效果是有梯度的,很多人混着用,其实差别不小。
Literal["a", "b"]是硬约束,翻译成 schema 的enum,是解码层面的事。只要模型服务方确实执行了 schema,超出范围的取值基本不会出现。代价是它只能表达有限的字面量,而且一旦你改了取值,所有引用处都要跟着改。
Enum子类也能转成enum,额外好处是可以带注释和自定义值映射。坑在于序列化:模型返回的是字符串,你却拿到一个Enum成员,中间那次转换如果没做好,"PAID"和Paid的大小写差异会直接让转换失败。我的做法是给枚举统一加上大小写不敏感的查找,或者干脆在 schema 里约定统一小写。
Annotated[str, Field(pattern=...)]表达的是格式约束,比如正则、长度、范围。它的约束强度取决于接收方是否真的执行了pattern。有些服务方对pattern的支持是“尽力而为”,也就是只作为提示,不强制。这一点必须实测,不能假设。我曾经以为加了pattern就万无一失,结果模型返回了一个不匹配的值,校验层才发现这层约束没生效,最后还是在本地补了一道正则。
简单的判断法则:能用 Literal 就别用 Enum,能用 Enum 就别用 pattern,能用 pattern 就别只靠提示词。约束越靠近解码层,你的清洗代码就越少。
4.4 自引用和递归类型
自引用类型(比如评论树Comment.replies: list[Comment])在类型系统里是完全合法的,在 schema 里必须用$ref表达,不能用内联展开,否则会无限递归。上面那段简化实现里的seen集合就是干这个用的。
问题在于,不是所有的模型服务方都好好支持$ref。有些解析器对$defs里的引用支持不完整,会导致整个 schema 被判定为非法,报出来的错还很晦涩。我的应对比较保守:递归深度超过两层的结构,一律不交给模型一次性生成,改成“生成一层,然后针对每个节点再调一次”。虽然调用次数多了,但结构稳定性高得多,而且每一层的 schema 都很小。
真要用递归,至少加两个刹车:一个是最大深度限制,一个是最大节点数限制。不加的话,模型有可能给你生成一棵很深很深的树,解析的时候递归爆栈,或者把你的数据库写满。这类问题的排查成本很高,因为错误现场在解析层,根因在生成层。
5. 六类高频翻车现场与逐层排查链路
5.1 JSON 外面裹了代码块或者多余寒暄
这是最常见也最好修的一类。表现是json.loads报Expecting value: line 1 column 1,你打印原始输出一看,前面是“好的,以下是提取结果:”,后面是json 和包裹。
排查链路很短:先打印原始响应的前 200 个字符和后 100 个字符,判断是纯文本包裹还是别的。修复手段分三层,从轻到重:第一层做清洗,用正则剥掉代码块标记和前后非 JSON 内容;第二层在提示词里明确“只输出 JSON,不要任何解释文字”;第三层用服务方提供的结构化输出模式,从解码层面约束。
我一般三层都上。清洗函数是兜底,提示词是降低概率,结构化输出模式是根治。只靠清洗的话,遇到模型在 JSON 中间插一句解释的情况,正则也救不了。清洗函数我写得比较保守,只剥开头结尾,不尝试修复中间内容,因为“猜”出来的 JSON 往往比报错更危险。
5.2 类型漂移:数字变字符串、布尔变文本
这类问题的表现是解析成功但校验失败,错误信息形如Input should be a valid integer, input was "3"。
排查的时候先分清是模型的问题还是你的 schema 的问题。有一种很隐蔽的情况:schema 里字段类型写对了,但上游提示词里给的示例是字符串形式的数字,模型照抄示例。这种情况改示例比改 schema 有效。
如果确认是模型输出不稳,有两个选择。一是放宽接收类型,用Union[int, str]然后在本地做转换,好处是链路变稳,坏处是你的类型系统从此多了一个脏点,后面所有用到这个字段的地方都得考虑两种形态。二是保持严格,靠重试解决。我倾向后者,但会加一个前置的轻量清洗:把"3"这类纯数字字符串在解析前统一规整。这个清洗很安全,因为只处理能无损转换的情况。
布尔值的情况更麻烦。模型可能返回"true"、"True"、"是"、1。字符串形式的布尔一律不能靠bool()转换,因为bool("false")是True,这个坑我见过不止一次有人踩。必须显式映射,把"true"/"1"/"yes"/"是"映射为真,其余映射为假,未识别的走null。
5.3 幻觉字段和静默丢字段
幻觉字段是指模型返回了你 schema 里根本没有的字段。很多校验库的默认行为是忽略多余字段,于是这些字段被静默丢弃,你完全不知道发生过。静默丢字段是反过来的情况,模型漏了某个必填字段,如果你的模型定义里这个字段恰好有默认值,它就被默认值悄悄填上了。
这两类问题的共同点是:不报错,只是数据不对。我的做法是同时打开两个严格模式:禁止多余字段(遇到就报错,触发回填),必填字段一律不给默认值。打开之后初期的报错量会明显上升,但你能看到真实情况。之后再根据错误分布决定哪些该放宽。
还有个排查技巧:把“模型返回的键集合”和“schema 期望的键集合”做差集,打进日志。这个指标比任何错误率都直观。我在一个项目里就是靠这个发现某个字段的命中率只有 40%,原因是提示词里对这个字段的描述有歧义,改了描述之后升到 92%。
5.4 多工具并行调用时的参数串台
模型支持一次返回多个工具调用的时候,容易出现参数错位:本该传给 A 工具的参数,传给了 B 工具。表现是调用成功但结果莫名其妙,或者参数校验在工具内部才报错。
排查这类问题,第一步是把每一次工具调用的名字和参数完整打日志,别只看最终结果。第二步检查工具描述之间的相似度。如果两个工具的描述都是“查询信息”,模型分不清是必然的。工具描述之间的区分度,比每个描述本身的准确度更重要。
修复上我一般做两件事:一是给工具名加领域前缀(order_query、user_query),让名字自带区分度;二是在每个工具的 docstring 里写一句“不要用本工具做 XX,那是另一个工具的事”。这句否定式说明效果出奇地好,模型对“不要”这类指令的敏感度比想象中高。
5.5 枚举值大小写和中文全角符号
这是个特别不起眼但特别高频的坑。schema 里写的是["draft", "paid"],模型返回"Paid";schema 里写的是半角引号,模型在中文语境下返回全角引号“,导致 JSON 解析失败。
前者的处理有两种:要么在 schema 层面统一约定全部小写并在描述里强调,要么在拿到值之后做一次规范化再比对。我用后者更多,因为改 schema 影响面大,而在入口处做一次strip().lower()成本极低。
后者的处理必须靠清洗。全角引号、全角逗号、全角冒号,在中文场景下的出现率比想象中高。我的strip_fence函数后来演化成了一个normalize函数,专门处理这类字符替换。要注意的是,替换全角字符时不能无脑替换,因为字符串值内部的全角标点是有意义的,只能替换结构位置上的。这个区分做起来有点绕,我的偷懒办法是先尝试解析,失败了再逐字符替换重试,两次都失败才报错。
5.6 长上下文下的 schema 漂移
这个坑最少见,但最难查。表现是同样的 schema、同样的提示词,短输入下一切正常,长输入(比如几万字文档)下字段开始缺失或者结构变形。
我的理解是,输入太长之后,schema 约束在模型注意力里的权重被稀释了。这不是某个框架能解决的,属于模型本身的特性。
应对手段有几个。一是把 schema 放在提示词的最后而不是最前,靠近生成位置,实测有效。二是把长文档切段,每段独立抽取再合并,这一步虽然增加调用次数,但成功率提升明显。三是把最关键的几个字段单独拎出来做一次轻量抽取,作为主流程的交叉验证。这三个我都用过,切段是最稳的,代价是合并逻辑要自己写,而且要注意跨段的实体对齐。
6. 把这套东西放到生产环境前要补的四件事
6.1 重试预算和降级路径
重试不是“再来一次”这么简单,它得有预算概念。我在每个调用点都会配置三个数:最大重试次数、最大累计 token 消耗、最大累计耗时。任意一个触顶就停止重试,走降级。
降级路径必须提前设计好,不能等到线上出问题才想。常见的降级有几种:返回带partial=True标记的对象,让下游知道哪些字段可信哪些不可信;返回一个空对象加错误码,让业务侧自己决定;切到备用模型或者备用提示词。我一般选第一种,因为它保留了部分价值,下游可以决定“只有主键可信就先入草稿表”。
这里有个细节值得说:重试的计费是叠加的。一次失败重试三轮,成本是四倍。如果你的业务对成本敏感,把重试预算压到两轮,用更好的提示词和更小的 schema 去提升一次通过率,往往比加大重试更划算。
6.2 缓存键怎么设计才不会误命中
结构化输出的缓存比普通文本缓存更危险,因为结果看起来永远“格式正确”,错了也不容易发现。
我的缓存键一定是三元组:模型标识 + schema 的哈希 + 规范化之后的输入哈希。三个都要,缺一不可。少模型标识,换模型后可能命中旧结果;少 schema 哈希,改了字段定义还在用老缓存,这是最坑的一种;少输入哈希,就不用说了。
schema 哈希这块有个容易忽略的点:哈希要基于“规范化后的 schema”,也就是键序固定的序列化结果。如果直接对字典做哈希,键序不同会导致哈希不同,缓存命中率暴跌。反过来,如果哈希算得太松(比如只哈希字段名不哈希类型),改类型的时候不会失效,就会命中脏数据。我现在的做法是把 schema 序列化成排序过的 JSON 再哈希,简单可靠。
6.3 日志里必须留痕的五个字段
排查这类问题的效率,几乎完全取决于日志留了什么。我现在的标准是每次调用至少记五个字段:原始请求的消息列表(脱敏后)、模型的原始响应文本、校验结果和失败原因、重试轮次、本次消耗的 token 数。
这五条里,第二条最容易被省掉,也最不该省。很多人只记解析后的对象,出了问题根本没法复现。原始响应还带着模型的“思考痕迹”,你能从里面看出它是理解错了还是被示例带偏了。
另外建议加两个聚合指标:一次通过率(第一轮就成功的比例)和平均重试轮次。这两个数比错误率更能反映链路健康度。一次通过率下降往往早于用户投诉,是个很好的先行指标。我在一个项目里就是靠它提前发现了某次提示词改动引入的回归。
6.4 schema 的版本管理
最后这条听起来像流程问题,实际上是技术债的高发区。schema 一旦上线,就变成了事实上的接口契约,下游可能已经按这个结构写好了消费逻辑。你随手加个字段、改个类型,下游可能直接崩。
我的做法是给每个 schema 一个显式的版本号,放在类名或者模块路径里,日志里带上版本号,缓存键里也带上。改字段的时候,先加新字段(给默认值),观察一段时间再把旧字段标记废弃,最后删除。这个流程很笨,但在多人协作的项目里能省掉大量沟通成本。
还有一个细节:schema 的变更要和提示词的变更一起发布。我见过只改 schema 不改提示词的情况,模型还在按老描述填字段,校验层按新结构检查,两边打架,错误率高得离谱,查了半天才发现是发布不同步。
真正跑下来,我的体会是这套东西的难点从来不在于“能不能跑通”,而在于“跑通之后能不能稳住”。类型注解带来的最大收益,不是省了那几十行转换代码,而是把描述收敛到了一处,让每次改动的爆炸半径变得可控。我在几个项目里都经历过从手写 schema 迁移到注解驱动,迁移当时的收益看起来只有几百行代码,真正的价值在后面每一次加字段的时候才体现出来——改一处,跑一遍测试,没有第二处需要同步。如果非要说一条最实用的建议,那就是先把严格模式全打开,让错误充分暴露,再一个个决定放宽还是修提示词,别一上来就图省事把多余字段忽略了,那些被静默吞掉的问题,最后都会以更难查的形式回来。