去年年底,我在收拾一个内部工具项目的烂摊子时,被一堆手写的 JSON Schema 折磨得够呛:三十多个工具函数,每个都要人肉维护一份参数描述,改一个字段名就得同时动三处地方,测试还经常漏改。后来我把那套东西重写了一遍,核心思路只有一句话——让 Python 类型注解自己说话,LLM 只负责填参数。这个思路落到的具体载体,就是本文要聊的 Magentic 框架:用 Python 类型注解驱动 LLM,做结构化输出与函数调用。它解决的不是"能不能调通模型"这种初级问题,而是"几十个工具、上百个字段,怎么让类型系统和模型约定不打架"的工程问题。适合已经把模型 API 跑通、正在往业务里塞工具调用的人看;如果你刚接触这块,前半部分的原理拆解也能帮你少走不少弯路。
1. 为什么值得用类型注解去驱动 LLM
1.1 手写 Schema 的三笔糊涂账
先说我踩过的坑。最早的做法很朴素:写一个工具函数,旁边手写一份 JSON 描述,塞进请求体里。跑十个工具没问题,跑到三十个就开始出事。
第一笔账是一致性。函数签名改了,比如把page_size换成limit,Schema 忘了改,模型照着旧字段名生成参数,本地校验直接炸,可报错信息只会告诉你"缺少必填字段 limit",你得回头翻半天才发现是描述没同步。这种 bug 不致命,但极其消耗耐心。
第二笔账是表达力。手写 Schema 的时候,人总会偷懒。参数明明是枚举,写成"type": "string"了事;字段明明是必填,为了省事没放进required。模型是很诚实的,你怎么描述它就怎么理解,于是你会在日志里看到status: "已完成"而你的代码只认"done"。
第三笔账是重复。工具调用的返回值往往也要结构化,前端要展示、下游要消费,于是你又要为返回值手写一份解析逻辑。参数一套、返回值一套,两套东西天长日久必然漂移。
类型注解驱动的好处就在于,这三笔账一次性结清:函数签名就是唯一事实来源,Schema 由它推导,返回值校验也由它推导,改一处全链路生效。你不再维护两份真相,只维护一份代码。
1.2 Magentic 的整体数据流长什么样
Magentic 的定位很清晰,它不做模型推理,也不做 HTTP 通信,它只干一件事:在 Python 的类型世界和模型的 JSON 世界之间做双向翻译。整条链路可以拆成四段。
第一段是采集。你把自己写的函数丢进注册表,框架读取它的签名、注解、默认值和文档字符串。这一步是纯静态的,不涉及任何网络请求。
第二段是编译。注解被翻译成一份符合规范的 JSON Schema,函数名和文档串被拼成工具描述,最终组装成模型 API 认识的tools数组。编译结果可以缓存,同一份代码反复调用不必重复计算。
第三段是生成与回填。模型看完工具描述,返回一个工具调用请求,里面是函数名加一段 JSON 字符串形式的参数。框架按名字找回原始函数,用参数模型做校验和类型转换,把 JSON 变成真正的 Python 对象。
第四段是执行与回传。函数真正跑起来,返回值按声明的返回类型序列化,作为工具结果塞回对话历史,进入下一轮。这四段走完,就是一次完整的函数调用闭环。
我特别喜欢这个分层的原因是,每一段都能单独测试。Schema 编译可以拿快照比对,参数校验可以脱离模型跑单元测试,执行环节更是纯函数。整套东西里唯一不确定的只有模型,其余部分都是确定性的——这对线上排障太重要了。
1.3 这套方案不适合哪些场景
说点劝退的话。类型注解驱动不是银弹,有几类情况我建议你绕开。
工具数量爆炸的场景。如果你有三百个工具,全量塞进一次请求,光 Schema 就能吃掉几千 token,模型还会因为选项太多而选错。这种时候要么做工具检索,要么按业务域拆分,别指望一套注册表打天下。
参数极度动态的场景。有些接口的参数是运行时才确定的,比如用户自定义表单,字段名和类型都来自数据库。类型注解是静态的,写不出来就是写不出来,硬套只会把代码拧成麻花,这种情况老老实实用字典加手写校验更省事。
对延迟极度敏感的场景。多轮工具调用意味着多次往返,每一轮都要等模型吐完。如果你的场景要求两百毫秒内出结果,那函数调用这条路本身就不合适,不如把逻辑写死在代码里。
模型能力偏弱的场景。类型注解生成出来的 Schema 往往比手写的更"规矩",字段多、嵌套深、$ref引用满天飞。能力一般的模型在这种结构面前很容易迷路,返回的参数经常缺斤少两。这种时候要么精简工具,要么降级成"让模型输出自然语言、本地用正则抽"这种土办法。
2. 类型到 Schema:转换规则逐条拆解
2.1 基础类型映射与几个反直觉的细节
基础映射看起来最没技术含量,但坑恰恰藏在这里。整数int映射成integer,浮点float映射成number,字符串str映射成string,布尔bool映射成boolean,这些都没争议。真正需要留神的是下面几个。
布尔和整数的边界。在某些语言里True等价于1,模型有时候会把布尔参数写成0或1。如果你的校验层做了宽松转换,0会被当成False,看起来能用,但语义已经歪了。我的做法是对布尔字段做严格校验,0/1直接判失败并把错误回传给模型,让它重来一次——宁可多一轮,也别让脏数据流进业务逻辑。
浮点数的精度问题。number在 Schema 里没有精度概念,模型可能返回3.141592653589793这种超长小数。如果你的业务是金额,绝对不能直接用float,要么声明成字符串再自己转Decimal,要么在校验层加约束。我在一个计费相关的工具上就吃过亏,0.1 + 0.2的老问题,线上对账差了三分钱,查了一下午。
日期时间别用原生类型。datetime对象没法直接序列化成 JSON,框架通常会退化成字符串。与其让它猜格式,不如你自己声明成str,然后在文档字符串里写清楚"ISO 8601 格式,例如 2024-05-01T09:00:00+08:00"。模型的模仿能力很强,给它一个例子,比你写三行规范描述都管用。
None和NoneType的处理。返回类型声明成None表示这个工具没有返回值,Schema 里对应null。但很多模型看到工具"没有返回值"会困惑,不确定调用是否成功。我的习惯是永远返回一个带ok字段的小对象,哪怕只是{"ok": true},模型对这种情况的理解明显更稳。
| Python 注解 | 生成的 Schema 类型 | 常见坑点 |
|---|---|---|
int | integer | 模型可能传"3"字符串,需宽松转换或严格拒绝 |
float | number | 无精度约束,金额场景慎用 |
str | string | 需在描述里补充格式约定 |
bool | boolean | 警惕0/1被误判 |
datetime | string | 必须自己约定格式并给示例 |
None | null | 建议改成返回小对象,语义更清晰 |
2.2 容器的展开逻辑:List、Dict、Optional 与 Union
容器类型是转换规则里最需要小心的一块,因为它们的组合会迅速放大复杂度。
List[X]和list[X]都展开成array,元素类型由X决定。空列表在 Schema 里没法表达"元素类型",所以框架只能靠items字段约束。这里有个实战经验:给列表加长度约束。模型很爱偷懒,你让它传一批 ID,它可能只传一个,也可能一口气传五十个。在描述里写清楚"1 到 20 个",比任何校验都提前一步。
Dict[str, X]展开成object加additionalProperties。这种结构在"动态键名"场景很好用,比如按城市名映射折扣率。但它对模型不太友好,因为模型不知道应该放哪些键。我的处理方式是在描述里给一个完整的键名清单,如果键名集合是有限的,干脆改成枚举加数组的结构,可读性反而更高。
Optional[X]等价于Union[X, None],展开成anyOf: [X, null],同时在required列表里被剔除。这里有个非常隐蔽的坑:模型倾向于省略所有可选字段。你声明了十个可选参数,模型可能一个都不填,然后在执行阶段你发现全是None,业务逻辑直接崩。解决办法是在默认值上下功夫——给可选参数一个合理的默认值,模型看到默认值往往就不填了,而你的代码拿到了一个能用的值。
Union[X, Y]展开成anyOf,这是歧义的重灾区。Union[str, int]这种组合,模型十次里有三次会选错。我的建议是:能用Literal就别用Union;实在要用,就加判别字段,让它变成可区分的结构。比如不要把"ID 可以是用户 ID 也可以是订单 ID"写成两个Union分支,而是写成{"kind": "user"|"order", "id": "..."},模型填空的成功率会高出一大截。
2.3 Pydantic 模型、dataclass、TypedDict 三种载体的取舍
三种载体都能用来描述结构化数据,但适用场景差别很大,选错了会在后期付出代价。
Pydantic 模型是功能最全的一种,支持嵌套、默认值、字段别名、自定义校验器、约束条件(最小长度、正则、数值范围)。它的缺点是引入了一个运行时依赖,而且模型实例化有开销。我的原则是:只要涉及参数校验,就用它,因为你要的本来就是校验能力,别的载体给不了。
dataclass胜在轻量、标准库自带、心智负担小。它适合描述"内部数据结构",比如工具执行完返回的结果对象。但它的校验能力很弱,只做类型注解层面的检查,运行时不会拦截非法值。如果你的返回数据来自自己的代码,用dataclass完全够用;如果来自模型生成的 JSON,别用它。
TypedDict是纯类型提示,运行时完全不校验,只在静态检查阶段起作用。它的价值在于给字典加上结构,比如你从某个第三方接口拿到一个字典,想告诉 IDE 和类型检查器它有哪些键,用TypedDict最合适。但把它用在模型输出的参数上,等于放弃了所有运行时保护,我不推荐。
实际项目里我是混着用的:参数用 Pydantic 模型,因为要校验;工具返回值用dataclass,因为轻量;跨模块传递的中间结构用TypedDict,因为不关心运行时。混用没问题,关键是每一处都想清楚"我到底需不需要运行时校验"。
2.4 把提示词写进类型:Annotated 与字段描述的威力
这一段是我认为 Magentic 这套思路里最有价值的部分:描述信息应该跟着类型走,而不是散落在提示词里。
Annotated[T, "这里写说明"]允许你在类型上附加元数据,框架会把它提取出来塞进 Schema 的description字段。这意味着你可以这样写:
from typing import Annotated, Literal from pydantic import BaseModel, Field class SearchQuery(BaseModel): keyword: Annotated[str, "搜索关键词,支持中英文,不要包含标点符号"] scope: Annotated[ Literal["title", "content", "both"], "搜索范围:title 只搜标题,content 只搜正文,both 全字段搜索", ] = "both" limit: Annotated[int, Field(description="返回条数,1 到 20 之间", ge=1, le=20)] = 10编译出来的 Schema 里,每个字段都带着人类的解释。模型看到limit的描述是"返回条数,1 到 20 之间",再配合minimum/maximum约束,出错的概率会显著下降。
这里有几个写作技巧值得单独说。描述要写"是什么"和"不要什么",比如"关键词,不要包含标点符号",比单纯写"搜索关键词"有效得多,因为模型最常见的错误就是把整句话当关键词传进去。枚举值要逐个解释,别只写"a"|"b"|"c",模型不知道b是干什么的,就会瞎选。给数值边界,1 到 20比合理范围内有用一百倍,模型对具体数字的服从度很高。
还有一个反直觉的发现:描述不是越长越好。我做过一次对比,把某个字段的描述从 20 字扩到 80 字,准确率反而下降了。后来想明白了,过长的描述会稀释关键信息,模型抓不住重点。控制在 30 字以内,只留最有信息量的那句话,这是我目前的经验值。
3. 动手:搭一个能跑的最小闭环
3.1 环境与目录结构
先说环境。Python 版本建议 3.10 以上,因为X | None这种写法在 3.10 之前不被支持,而它比Optional[X]直观太多。依赖方面需要模型服务商的 SDK、Pydantic,以及框架本身。
目录我习惯这样组织,逻辑清晰,后续扩展工具也不会乱:
project/ ├── tools/ # 工具定义,按业务域分文件 │ ├── __init__.py # 统一导出,注册入口 │ ├── search.py │ └── order.py ├── schemas/ # 参数与返回值模型 │ └── models.py ├── agent.py # 循环控制 ├── client.py # 模型调用封装 └── tests/ └── test_schema.py # Schema 快照测试把工具和模型分开是有原因的:模型类会被多个工具复用,放在一起容易循环导入;工具按业务域分文件,则方便后期做"按需加载"——工具多了之后,你肯定要按场景挑一部分塞进请求,而不是全量。
初始化的时候有个细节:注册表要在导入时自动填充。我一开始手动一个个register(fn),后来工具多了就漏注册。改成在tools/__init__.py里遍历模块的成员,把带特定装饰器的函数自动登记,省事且不会漏。
3.2 定义工具函数与返回模型
工具函数的写法有几个硬性要求,违反了框架就编译不出正确的 Schema。
第一,注解必须完整。参数和返回值都要有类型,一个都不能省。我见过有人写def foo(x, y):,框架只能编译出空 Schema,模型瞎填,最后报一堆莫名其妙的错。第二,文档字符串必须写,它会变成工具描述,是模型判断"该不该调这个工具"的唯一依据。第三,函数体要短,工具函数只做一件事,业务逻辑放到下层服务里去。
from dataclasses import dataclass from typing import Annotated from schemas.models import SearchQuery @dataclass class SearchResult: ok: bool total: int items: list[dict] def search_documents(query: SearchQuery) -> SearchResult: """在文档库中检索与关键词匹配的条目。 适用于用户询问"有哪些文档提到某某"、"查找关于某某的资料"这类需求。 不适用于精确的 ID 查询,那种情况请用 get_document_by_id。 """ ...注意文档字符串里我写了"不适用于……"。这一句话价值极高,它告诉模型这个工具的边界在哪。工具之间最容易出的错不是"该调没调",而是"调错了工具"。把你的工具想象成给新同事看的说明书,写清楚"什么时候别用它",比写十行功能描述都管用。
返回模型用dataclass,因为它只做序列化不做校验。但如果返回值要给前端消费、要做严格约束,我会换成 Pydantic 模型,加上Field约束,让非法数据在边界上就被拦下来。
3.3 编译 Schema 并组装请求
编译这一步没什么玄机,但要理解产物长什么样。以search_documents为例,编译结果大致是:
{ "name": "search_documents", "description": "在文档库中检索与关键词匹配的条目……", "parameters": { "type": "object", "properties": { "query": { "type": "object", "properties": { "keyword": {"type": "string", "description": "搜索关键词……"}, "scope": {"type": "string", "enum": ["title", "content", "both"]}, "limit": {"type": "integer", "minimum": 1, "maximum": 20, "default": 10} }, "required": ["keyword"] } }, "required": ["query"] } }看到这里你应该能理解为什么参数模型要多包一层:query这个字段本身是个对象,模型要在它内部继续填。层级越深,模型出错的概率越大。所以我的建议是控制嵌套深度在两层以内,超过两层就考虑把工具拆开。
组装请求时,把编译好的工具数组和对话历史一起发出去。这里有个省钱技巧:编译结果做缓存。Schema 的生成是纯计算,同样的代码每次结果都一样,缓存起来能省掉可观的 CPU 开销——尤其在工具多、调用频繁的场景下。
from functools import lru_cache @lru_cache(maxsize=128) def compile_tool(fn_name: str) -> dict: fn = REGISTRY[fn_name] return build_schema(fn)用lru_cache挂在函数名上,简单直接。要注意的是开发阶段改了代码得重启进程,或者加个环境变量做开关,否则你会疑惑为什么改了描述没生效,追半天。
3.4 解析返回值、校验失败与重试策略
模型返回的工具调用,参数是一段 JSON 字符串。解析这一步必须防御性编程,因为现实中会遇到各种奇形怪状的输入。
最常见的是JSON 被包在 Markdown 代码围栏里,比如json {...}。有些模型即使在工具调用模式下也改不掉这个习惯。处理办法很简单,正则剥掉围栏再解析。第二常见的是尾部多一个逗号,或者单引号代替双引号,这类问题用一个宽松的解析器能兜住大部分。第三是参数被包了一层,比如模型返回{"query": {"query": {...}}},多套了一层壳。这种没法自动修,只能判失败重试。
校验环节我是这么设计的:先尝试严格解析,失败则尝试宽松修复,修复后依然失败就把错误信息原样回传,让模型自己改。整个流程最多重试两次,两次还不行就抛出异常,交给上层处理。
def parse_and_validate(fn_name: str, raw_args: str): fn = REGISTRY[fn_name] param_model = fn.__annotations__["query"] for attempt in range(3): try: data = robust_json_loads(raw_args) return param_model.model_validate(data) except Exception as exc: if attempt == 2: raise raw_args = ask_model_to_fix(raw_args, str(exc))这里有个非常重要的经验:错误信息要写给模型看,不是写给人看。Pydantic 默认的报错格式对模型来说信息量太低,我会把它简化成"字段limit的值 50 超出范围,应在 1 到 20 之间"这种人话。实测下来,用简化后的错误信息重试,第二次就通过的比例明显更高。
4. 函数调用闭环:从单次调用到多步执行
4.1 一次调用里到底发生了几件事
把时序摊开看,一次成功的单工具调用包含这么几步:
- 本地组装消息列表,把用户输入和工具描述发出去;
- 模型返回工具调用意图,包含函数名和参数 JSON;
- 本地查表找回函数,校验参数;
- 执行函数,捕获异常;
- 把返回值序列化成字符串,作为工具结果追加到消息历史;
- 带着新的消息历史再请求一次模型;
- 模型看到工具结果,生成最终回复。
注意第 6 步:函数执行完不是结束,还要再请求一次。这是很多人第一次写的时候会漏掉的地方——执行完就直接把结果返回给用户了,结果用户看到的是原始 JSON 而不是自然语言回答。
还有一个细节:工具结果必须以字符串形式回传,且要带上工具调用的 ID,让模型能对应上"这是哪个调用的结果"。如果你的返回值是个复杂对象,先序列化成 JSON 字符串。这里别偷懒用str(obj),Python 的repr格式模型看不懂,必须是合法 JSON。
4.2 多工具串联与依赖传递
真实的业务问题很少一个工具能解决。用户问"上个季度华东区卖得最好的三个产品,各自的库存还有多少",这条链路要走两步:先查销售排行拿到产品 ID,再拿 ID 去查库存。
串联的关键在于中间结果的传递。第一步的返回值必须能被模型看见,模型才能提取出产品 ID 填进第二步的参数。这就要求你的返回结构对模型友好——字段名要语义化,别用缩写。返回{"p": [...]}和返回{"products": [...]}对模型来说难度完全不同。
我在一个项目里踩过这个坑:工具返回的是分页结果,字段叫next,值是游标字符串。模型完全不知道这是什么,每次都在参数里瞎填。后来改成{"has_more": true, "next_cursor": "abc123"},再在描述里写一句"要把 next_cursor 原样传给下一页请求",成功率立刻上去了。
另一个经验是限制单轮的工具调用次数。我会在循环里设一个上限,比如 10 步,超过就强制收尾。没有上限的循环是很危险的,模型有时候会陷入"调用工具-结果不满意-再调用"的死循环,烧掉大量额度还出不来。到达上限时不要让程序崩溃,而是给模型发一条"已达步数上限,请基于已有信息作答"的提示,让它体面收场。
4.3 结构化输出模式:不走工具调用的那条路
有些场景你要的不是"让模型调工具",而是"让模型填一张表"。比如从一段客服对话里抽取订单号、金额、诉求类型。这种需求用工具调用也能实现,但有点绕——你得虚构一个"保存结果"的工具。
Magentic 这类框架通常也支持结构化输出模式:把一个 Pydantic 模型作为输出格式声明,模型直接返回符合该模型的 JSON,本地再校验。相比工具调用,它的优点是链路更短、少一轮往返,缺点是模型不能在这中间做别的事。
from pydantic import BaseModel from typing import Literal class TicketInfo(BaseModel): order_id: str | None amount: float | None category: Literal["退款", "换货", "咨询", "投诉"] urgency: Literal["高", "中", "低"] info = extract(TicketInfo, conversation_text)选哪种模式,我的判断标准很朴素:需要模型采取行动就用工具调用,只需要模型给出答案就用结构化输出。抽取、分类、摘要这类纯读操作,结构化输出更合适;查询数据库、发消息、改状态这类写操作,必须走工具调用。
4.4 循环控制与终止条件
多步执行的框架,最容易写错的地方就是终止条件。写太松会死循环,写太紧会让本来能解决的问题被半路截断。
我的做法是三重保险。第一重是步数上限,按业务复杂度定,简单问答场景 5 步,复杂分析场景 15 步。第二重是重复检测,如果连续两次调用同一个工具且参数相同,直接判为死循环并中断。这个判断很简单,把函数名加参数 JSON 做哈希,存进一个集合就行。第三重是空转检测,如果模型的回复里既没有工具调用也没有实质内容,说明它卡住了,直接返回兜底文案。
这三重里,重复检测是最有用的。实际跑下来,模型卡死的场景八成都是"用同样的参数反复调同一个工具",因为它觉得结果不对,想再试一次。检测到之后我会给它发一条提示:"你刚刚已经用相同参数调用过这个工具,结果是 X,请基于此继续。"通常它就换个思路了。
5. 踩坑实录:类型系统与模型之间的冲突
5.1 模型看不懂的类型表达
类型系统能表达的东西,远比模型能理解的多。这是我踩得最深的一片坑。
最简单的例子是带单位的数值。你声明duration: int,描述里写"时长",模型可能传3600,也可能传"1小时"。前者对,后者炸。解决方式不是加校验,而是在字段名和描述里把单位写死——duration_seconds: int,描述写"单位:秒"。字段名带单位这件事在 Python 圈子里被认为不够优雅,但在 LLM 场景下,它是最高效的约束手段。
再深一点的是嵌套的联合类型。比如list[Union[Cat, Dog]],模型要在数组里给每个元素选一个分支,它大概率会选错或者混搭。我的建议是彻底放弃这种结构,改成{"kind": "cat"|"dog", ...}的判别式。
还有一个隐蔽的问题是泛型。Response[T]这种写法,Schema 编译器可能处理不了,或者退化成any。用泛型之前先测一遍编译产物,看看实际生成了什么。我见过编译出来{"type": "object"}什么都没约束的情况,模型随便传都能过校验,然后在业务代码里爆炸。
5.2 那些"看起来合法"的脏数据
比类型不匹配更麻烦的是类型对但语义错的数据。
典型的例子是日期格式。due_date: str,模型传"明天",类型完全合法,业务逻辑直接崩。这种问题的根源是你把格式约定放进了人类的大脑,而没有放进类型描述。解决办法只有两条:换成datetime类型让框架去约束,或者把格式示例写进描述。
另一个高频问题是枚举值的近义替换。你定义status: Literal["pending", "done"],模型传"completed"。它没错,语义上就是"完成",但你的代码不认。这种情况下我会在枚举上写清楚每个值的含义,并且把数量控制在 3 到 5 个之间。枚举值超过 8 个,模型选错的概率会明显上升。
还有一类是数值越界但通过校验。你声明quantity: int,模型传-5。类型对,但业务上不可能。这类问题必须靠Field(ge=1)这种约束拦住,别指望模型自觉。
| 问题现象 | 根因 | 处理方式 |
|---|---|---|
| 传入"1小时"而非秒数 | 单位未在字段名/描述中固定 | 字段名带单位,描述写示例 |
| 枚举值被近义词替换 | 枚举值未解释含义 | 逐个写描述,控制数量 |
| 日期传"明天" | 格式约定只存在于人脑 | 换成日期类型或写示例 |
| 数值越界但类型合法 | 缺少范围约束 | 加ge/le等约束 |
泛型退化成any | 编译器不支持复杂泛型 | 提前检查编译产物 |
5.3 Schema 瘦身:token 是真的要花钱的
工具多了之后,你会发现请求的 token 消耗涨得飞快,而且大部分花在了工具描述上。一份复杂工具的完整 Schema,两三千 token 很正常,十个工具就是两三万。
优化的方向有几个。第一,精简描述。我前面说过描述控制在 30 字内,这不仅是准确率问题,也是成本问题。第二,砍掉用不上的工具。很多工具只在特定场景才需要,没必要每轮都带上。我会按对话的阶段动态加载:刚开始只给"搜索"和"澄清"两个工具,进入具体业务后再挂载对应的工具集。第三,简化嵌套。深层嵌套的 Schema 体积膨胀很快,把三层嵌套压成两层,token 能省三分之一。
还有个不太直观的优化:参数名别太长。international_shipping_address_detail这种名字,在 Schema 里会重复出现多次(属性名、required 列表、可能的 $ref),累加起来不小。intl_addr虽然不好看,但确实省 token。这个取舍看你的场景,工具少的时候无所谓,工具多的时候值得抠。
5.4 递归与自引用:能不用就别用
树形结构、评论嵌套、目录层级,这些天然的递归结构用类型表达很自然,但送给模型就是灾难。
原因有两个。一是Schema 会通过$ref自引用,有些接口对递归引用的支持不完整,直接报错;二是模型无法判断递归深度,它可能生成一个十层嵌套的对象,你的解析器直接爆栈。
我的处理方式很粗暴:类型层面禁止递归。树形结构改成扁平列表加parent_id,让业务代码自己去组装。多出来的那点组装逻辑,比调试模型生成的畸形数据要省心得多。如果实在绕不开,就限制深度,比如声明max_depth: int,然后在描述里写"最多三层"。
6. 让这套东西能上生产:测试、日志与成本
6.1 给类型层写测试
类型驱动的最大好处是大部分逻辑可以脱离模型测试,这一点必须用起来。
第一类是Schema 快照测试。对每个工具编译一次,把结果存成文件,代码改动后比对差异。这样任何一次不经意的签名修改都会被发现。我第一次跑这个测试就抓到了三个问题:一个字段的可选性变了,一个枚举少了个值,还有一个返回类型忘了标注。
第二类是参数校验的边界测试。给每个参数模型喂各种非法输入——空字符串、超长字符串、负数、None、嵌套对象——确认该拒的都拒了。这类测试写起来枯燥,但能挡住绝大多数线上事故。
第三类是录制回放。把线上真实的模型返回(包括那些奇形怪状的脏数据)录下来做成测试用例,用固定的输入跑一遍解析逻辑。这比任何 mock 都真实,因为脏数据的形态你根本想象不出来。我现在的习惯是,每次线上报了参数解析错误,就把那段原始返回录进测试集,让它变成永久回归用例。
def test_parse_markdown_wrapped_json(): raw = '```json\n{"keyword": "报表", "limit": 5}\n```' result = parse_and_validate("search_documents", raw) assert result.keyword == "报表" assert result.limit == 56.2 日志:把黑盒变白盒
多步执行的系统,没有日志基本没法排障。我记录的字段有这么几项,缺一不可。
完整的消息历史,包括每一轮的工具调用和结果。每一步的耗时,用来定位是哪一轮慢。token 消耗,按轮次记录,方便算成本。校验失败的原始输入和错误信息,这是最关键的,因为它是你优化描述和类型的直接依据。
日志的粒度我建议到"每次工具调用一条"。格式上不用太讲究,JSON 行就够,方便后面 grep 和聚合分析。有个小技巧:给每次会话生成一个 ID,贯穿所有日志,这样排查单个用户的问题时直接按 ID 过滤就行。
还有一点容易被忽略:记录模型对工具选择的犹豫。有些模型会返回"我打算调用 A,但也在考虑 B"这种话,这往往意味着你的两个工具描述有重叠。看到这类日志,就该去改描述了。
6.3 成本与延迟的取舍
工具调用的成本是普通对话的好几倍,因为每一轮都要重新发送完整的消息历史和工具描述。一轮对话走五步,token 消耗可能是单轮的五倍以上。
控制成本的手段,我按效果排序。第一是减少轮次,把能合并的工具合并,能一次查完的别分两次。第二是精简历史,早期的工具结果可以截断,只保留关键字段,没必要把整个列表塞回去。第三是缓存,同样的查询结果缓存起来,命中就不必再走一遍模型。
延迟方面,最大的瓶颈是串行轮次。如果你的场景允许,可以让模型在一轮里返回多个独立的工具调用,本地并发执行。框架一般支持这个,关键是你的工具之间不能有依赖,否则并发会出错。
最后说一个心态上的建议。这套东西的准确率不可能是 100%,你要接受"百分之几的失败率"并设计好降级路径。我的做法是:校验失败先重试,重试失败再退回"让模型输出自然语言、本地人工兜底"的路径,同时打点上报。把失败设计成流程的一部分,比追求零失败要现实得多。
我个人在这套东西上折腾了小半年,最大的体会是:类型注解不是给模型看的,是给你自己看的。模型只是顺便受益于这份清晰。你把它写清楚了,模型的表现自然就好了;你写得含糊,加再多提示词技巧也救不回来。所以与其研究怎么"调教"模型,不如回头把函数签名和文档字符串写扎实——这部分投入的回报率,比任何技巧都高。