ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

用类型系统驯服 LLM 输出:从 JSON Schema 到 Function Calling

2026/8/26 9:25:16 拓冰建站 浏览量
用类型系统驯服 LLM 输出:从 JSON Schema 到 Function Calling 如果你正在用 LLM API 做应用一定遇到过这种情况模型返回一段自然语言你按JSON.parse直接崩了或者它返回的字段跟你想的不一样前端拿到了undefined排查半天才发现是模型输出结构变了。Types with AI这个方向要解决的问题就是这一类通过类型系统提前定义 LLM 的输入输出结构让模型返回符合 schema 的数据再交给代码做校验。它适合 TypeScript/Python 后端起服务、AI Agent 工具链、以及任何要求输出结构化的业务场景。最值得关注的不是“类型定义”本身而是“先有边界再让模型自由发挥”这套协作方式。1. 类型系统和 LLM 之间到底缺什么1.1 没有类型约束时LLM 输出为什么会毁掉你的代码很多人在第一次接入 LLM 时都会把模型当作一个“智能 JSON 生成器”。提示词里写一句“请以 JSON 格式返回”然后直接JSON.parse(response)。单次测试没问题等到业务一跑起来问题就集中出现了。第一个问题是格式不稳定。模型可能返回 Markdown 代码块比如{ name: 张三 }有时候把 json 标记也带进来。你JSON.parse一个带代码块标记的字符串直接抛异常。即使你让模型“不要输出多余内容”不同模型、不同温度参数的返回行为也不一样。只要有一次输出是自然语言你的下游逻辑就断了。第二个问题是字段缺失。你要求输出name、age、city结果模型漏了age。即使整体是合法 JSONundefined也会沿着代码一路传下去。如果没有类型检查这个问题会在很晚才暴露。第三个问题是类型错误。age要求是数字模型返回了字符串26。或者name要求数组模型返回了对象。这些问题的根源是提示词对模型的约束是“软约束”模型并不理解你的代码需要什么结构。它只是根据概率生成看起来像的东西。所以核心矛盾在于代码需要精确的结构而 LLM 天然是概率输出。类型系统就是一层硬约束把不稳定的输出挡在业务逻辑之外。1.2 类型系统能管住的是“结构”不是“语义”这里要先澄清一个边界。类型系统可以约束输出“长什么样”比如字段是否存在、类型是否是 number、数组长度是否满足条件。但它约束不了“这个字段的内容是否真实”。模型返回了city: 上海类型校验可以通过但这个城市可能并不存在或者它不是用户真实所在的城市。所以类型系统解决的是“结构安全”不是“语义正确”。理解这一点很重要。很多人以为只要定义好类型LLM 输出就可靠了。实际上类型系统帮你解决了最麻烦的序列化、缺失、类型漂移问题但模型幻觉、业务错误仍然要靠提示词、RAG、人工审核和业务规则去约束。类型安全不等于模型可信。我在实际项目里通常把类型校验放在两个位置。第一层是入口校验模型输出先经过 schema 校验不合法就重试或走兜底。第二层是业务校验字段结构合法之后再做数值范围、字段关系、业务规则判断。这两层各管各的不要混在一起。2. 先跑通最小闭环给 LLM 输出定义一个类型2.1 环境Node.js TypeScript zod 或 Python Pydantic先别急着把整个系统都接上类型。第一步先跑通“单次模型调用 类型校验”的最小闭环。常用组合有两种TypeScript 环境用zod做运行时类型校验Python 环境用pydantic定义模型输出结构。如果你的后端是 Node.js我一般推荐 TypeScript zod。原因很简单TypeScript 类型是编译期的运行时不生效zod 既能定义类型又能在运行时校验数据。两者配合一套 schema 同时解决类型推导和运行时校验。Python 端则把 pydantic 模型既当作数据容器又当作校验器。pydantic 的model_validate可以直接接模型返回的 JSON非常直观。这里不需要在意具体版本。落地时先确认你的 Node 或 Python 版本能安装对应依赖即可。2.2 定义输出 schema 并让模型按 schema 返回假设我们要做一个“招聘信息解析”工具。用户上传一段文本我们让 LLM 提取出职位名称、薪资范围、工作地点、技能要求。用 TypeScript 定义如下import { z } from zod; const JobSchema z.object({ title: z.string(), salary: z.object({ min: z.number(), max: z.number(), currency: z.string().default(CNY), }).nullable(), city: z.string(), skills: z.array(z.string()), }); type Job z.infertypeof JobSchema;这个 schema 表示LLM 必须返回一个对象title是字符串salary可以是 null 或者包含min/max数字的对象city是字符串skills是字符串数组。接下来在调用 LLM 的提示词里把 JSON Schema 或结构示例传给模型。比如从下面文本中提取招聘信息返回 JSON必须符合以下结构 { title: string, salary: { min: number, max: number, currency: string } | null, city: string, skills: [string] } 只返回 JSON不要输出额外内容。 文本 {用户输入}模型返回后直接用JobSchema.safeParse(JSON.parse(content))校验。2.3 解析和校验成功和失败的判断标准解析和校验不能只看“能不能跑通”。判断标准是模型返回的字符串能否被JSON.parse解析解析后的对象是否通过JobSchema校验校验失败时错误信息能否明确告诉你是哪个字段出了问题。zod 的safeParse不会抛异常而是返回一个 result 对象。你可以这样处理import { JobSchema } from ./schema; function parseJob(content: string) { let data: unknown; try { data JSON.parse(content); } catch { return { ok: false as const, error: invalid_json }; } const result JobSchema.safeParse(data); if (!result.success) { console.error(result.error.issues); return { ok: false as const, error: result.error }; } return { ok: true as const, data: result.data }; }第一次跑通时你会遇到几个常见失败。最常见的是模型在 JSON 前面加了说明文字导致JSON.parse失败。第二种是currency字段缺失但 schema 里给了一个 default 值zod 会自动补上。第三种是skills返回成逗号分隔字符串路径上还是类型不匹配。我的建议是第一次不要急着修所有错误先跑 5 条输入统计失败原因。大多数情况可以通过调整提示词解决少部分情况需要通过代码兜底。把失败原因写进日志这比到处加 try/catch 更有用。3. 把键值对换成函数调用进阶的 LLM 类型交互3.1 让模型按 JSON Schema 返回而不是自由发挥如果只是解析一段文本上面已经够了。但如果做 Agent、工具链、复杂任务编排我更推荐把“定义结构”改成“定义函数”让模型按函数签名来返回。这个概念在网络热词里经常被叫做Function Calling或Tool Calling。它的核心是你在请求里告诉模型有哪些工具、每个函数有哪些参数、参数类型是什么。模型根据用户输入决定调用哪个函数并生成符合参数结构的 JSON。比如我们有一个函数function searchCandidate({ keyword, city, page }: { keyword: string; city?: string; page?: number }) { // 查询候选人库 }API 请求里传入一个tools数组其中描述了这个函数的参数 schema。模型输出并不是直接给你 JSON 回答而是给出一个“要调用这个函数”的指令参数已经按类型组装好。在 TypeScript 里通常这么定义const tools [ { type: function, function: { name: searchCandidate, description: 搜索候选人, parameters: { type: object, properties: { keyword: { type: string }, city: { type: string, nullable: true }, page: { type: number, nullable: true }, }, required: [keyword], }, }, }, ];这种方式的优势是模型天生被训练成输出符合 schema 的工具调用而不是自由文本。相比“请返回 JSON”这种软提示函数调用在多数模型上更稳定。你甚至可以让模型同时调用多个工具比如先搜索候选人再给每位候选人生成邀约文本。如果你用的模型不支持原生 function calling可以退回上一节的方式把 JSON Schema 放在提示词里让模型按 schema 输出。只是稳定性需要额外验证。不同模型对 schema 的理解水平差异很大不能想当然。3.2 处理校验失败重试、修复 prompt、兜底默认值无论用哪种方式校验失败都是常态。不要指望加一个 schema 就能 100% 成功。你需要一套失败处理策略而且这套策略要有明确的执行顺序。我的经验顺序是先重试一次如果解析失败把错误信息拼回提示词让模型重新生成。比如“你刚才的输出不是合法 JSON请只输出 JSON”。对简单任务重试 1 到 2 次成功率会明显提升。再修复输入如果重试还失败检查原始输入。输入文本里包含大量符号、列表、非 UTF-8 编码都会影响模型表现。把输入截断或清洗后再试。最后走兜底默认值如果业务允许用 default 值填充缺失字段如果业务不允许记录日志并进入人工处理队列而不是直接抛异常。这里要特别注意不要为了追求“成功返回”而放宽 schema。比如把number改成any短期看业务通了长期看脏数据会直接进入数据库。3.3 批量调用时的类型检查、并发和命名单条任务跑通之后很多人就开始批量处理。这时最容易出问题的是并发和输出管理。批量任务建议按这个顺序来先跑 10 条样例确认单条成功率达到你的预期。再开小并发比如 3 到 5确认接口限流和请求超时。最后再上完整批次并且每条任务的结果单独落盘带上输入源 ID。批量任务的类型检查不是只检查第一个结果而是每一条都要检查。日志里应该有输入文件路径、任务 ID、校验结果、错误字段、耗时。我一般会用一个队列脚本每条任务把原始输入和 schema 解析结果都存成 JSON 文件。校验失败的任务单独放进failed目录方便后续重试。这样即使中间进程崩了也不会丢进度。output/ 2025-06-05/ job_001/input.txt job_001/output.json job_001/meta.json job_002/...判断批量任务是否成功不能只看“最后没报错”。要看成功条数、失败条数、失败原因分布、平均耗时。如果失败率超过 5%先停下调整提示词或 schema不要盲目加大并发。4. 实际踩过的坑和排查顺序4.1 模型不按 schema 输出先查提示词还是先查类型很多人遇到模型输出不合法第一反应是改 schema。我踩过的坑恰恰是反过来的先改提示词再查输出样例最后才动 schema。排查顺序应该是看模型返回的原始内容确认是格式问题还是内容问题。看你的提示词里是否给出了明确的 schema 示例。模糊表达“按 JSON 返回”是最常见的原因。看 schema 本身是否太复杂。字段一多、嵌套一深模型就容易切错路径。最后再怀疑模型版本或 API 参数。有一种很隐蔽的情况模型严格按照提示词返回了 JSON但你 schema 里定义的是skills: z.array(z.string())模型返回了skills: Python, Java。这时候不是模型“不听话”而是你提示词里的示例字符串没有体现数组结构。把示例改成[Python, Java]问题马上消失。所以排查时一定要把“提示词里的示例”和“代码里的 schema”对照起来。两边不一致模型只会跟提示词走。4.2 类型校验通过了但数据还是空的怎么办类型校验通过只代表结构合法不表示内容有效。比如模型返回了{ title: , salary: null, city: unknown, skills: [] }这个结果完全符合 schema但对业务没有价值。这种情况不能靠类型系统解决需要在业务层加校验规则。例如title不能为空字符串salary.min和salary.max必须大于 0skills数组至少包含一个元素city不能是unknown、N/A这类占位值。我建议把这类业务校验写在 schema 之外单独维护一个validateBusinessRule(data)函数。这样做的好处是类型校验错误要重试模型业务校验错误可能重试模型也可能直接丢弃输入。两者处理策略不同放一起会混淆。4.3 不同模型、不同 API 版本对 schema 的支持差异同一个 schema在不同模型上的表现可能天差地别。一些商业化模型对 JSON Schema 的支持较好复杂嵌套也能保持结构。开源小模型可能只支持简单结构字段一多就会出现截断、字段名篡改、甚至直接不输出 JSON。如果你切换过模型一定要重新跑一遍样例不要假定“模型能力差不多”。另外API 版本也会影响。有些模型的function calling参数格式改过一版旧写法仍然兼容但行为可能不同有的模型需要传入response_format参数才能固定输出 JSON。这些信息要以你所用模型的最新文档为准但有一个通用做法在代码里把模型名和 API 版本显式写在配置中并且为不同模型准备不同的 prompt 模板和 schema 简化版本。我曾经遇到过一个案例生产环境换了一个更小的模型原本的复杂 schema 完全跑不动返回结果全是{}。最后把 schema 拆成三个简单 schema分三步让模型分别输出再合并结果才勉强稳定。这提醒我schema 的复杂度和模型能力要匹配不是越完整越好。5. 生产环境里别把类型当万能药5.1 类型安全不解决模型幻觉和业务逻辑错误类型系统能拦截结构错误但拦不住以下三类问题幻觉模型编造了一个不存在的候选人结构完全合法。逻辑错误模型把 A 公司的薪资写到了 B 公司的职位上字段都是 string/number。价值判断错误模型把医疗建议包装成事实输出结构没有任何问题。这些问题的责任不在类型层。你仍然需要 RAG 检索增强、知识库校验、输出审核、人工抽检等手段。类型安全只是地基不是保险丝。5.2 从原型到生产还要补日志、队列、监控和降级策略如果你只是学习跑通最小闭环就够了。但如果做生产系统下面这些东西不能省。日志每条 LLM 调用都要记录请求 ID、输入摘要、输出原文、校验结果、耗时、模型名、提示词版本。日志不需要存全文但至少能帮助你在出问题时回溯。队列批量任务要做队列管理。任务进来先入队失败的任务能自动重试重试多次还失败就进入死信队列。不要在主线程里同步调用模型否则一旦上游超时整个服务都会卡住。监控关键指标是调用成功率、schema 校验失败率、重试率、p95 耗时、token 消耗。校验失败率突然升高往往提示模型版本变化、提示词被改动或输入分布变了。降级当 LLM 服务不稳定时系统要有降级方案。比如暂时返回缓存数据或者直接提示用户稍后重试。类型校验本身不提供降级能力降级策略要在业务层设计。5.3 什么情况下适合用类型约束什么情况下不如直接提示词不是所有 LLM 任务都适合上类型约束。我的判断标准是这样的适合用类型约束的任务需要把结果存数据库结果要直接传给另一个函数多个模型调用必须组合成一条流水线对输出结构有强依赖。不适合用类型约束的任务开放式问答、聊天、写作这些场景应该直接返回文本高度依赖创造性的内容类型约束反而让模型表现变差没有后续逻辑处理的纯展示场景类型约束增加了不必要的复杂度。有些开发者喜欢把所有输出都做成 union 类型或者泛型其实没必要。类型系统服务于数据边界不是用来装饰代码的。如果一个任务只需要模型给出一段话定义{ content: string }就是画蛇添足。5.4 下一步把 schema 作为团队协作接口类型系统还有一个很重要的作用在不同模块之间传递“契约”。后端 LLM 调用方返回的数据格式前端或下游服务需要知道。以前靠文档同步文档更新不及时接口就乱了。有了 schema可以直接把定义拆成一个公共包前端和后端共用。模型输出、后端解析、前端展示全部走同一套 schema字段名就不容易出现中文/英文不一致、大小写不一致、时区单位不一致的问题。在团队协作时我建议把 schema 文件放在独立目录命名、版本、变更记录都写清楚。每次改动 schema先跑一遍已有的样例集确认对历史任务是否兼容。这一步比大多数人想的更重要。因为模型输出是概率性的你很难保证新 schema 对旧输入依然友好。实际落地时也要给 schema 留一个“扩展字段”。比如extra: Recordstring, unknown用来承接后续业务需要的临时字段避免每次字段调整都要改 prompt、改 schema、改测试数据。但这只作为辅助核心字段仍然要显式定义。写在最后的实践建议关于Types with AI我更愿意把它理解成一种开发习惯让类型和模型输出之间建立明确的契约先定义边界再让模型在边界内工作。最理想的状态是模型输出进到代码前已经过了一层结构校验校验不过的信息要么重试要么进人工而不是直接污染上层逻辑。如果你刚开始学建议先拿一个简单的文本解析任务练手。定义 5 个字段的 schema跑 20 条真实输入记录失败原因再逐步加入重试、默认值、日志和批量处理。这套流程走一遍比看十篇教程都有用。真正生产落地时最该盯住的不是“能跑通”而是输入格式、资源占用和失败重试。类型系统会让你的代码更稳但它不会自动帮你解决数据质量和模型可信度。把每一层边界都看清楚LLM 应用才能真正从 Demo 走向可靠。