用 Schema 定义和校验输出结果
用 pi-schema 定义字段并推导类型,理解强制转换与 TypeBox 特性边界,检查函数名与源码证据,拦截格式合法但不可信的数据。
用 Schema 定义和校验输出结果
JSON 可以被解析,只说明它符合 JSON 语法。parameters 是字符串还是数组、函数名是否出现在代码中,都需要额外检查。本篇把上篇的未知数据收敛成程序可使用的对象。
沿用JSON 输出练习的项目和 src/lesson.ts。这一篇可以完全离线运行,不需要模型 Key。
pi-schema 是什么
Pi 的 Schema 校验由独立包 @earendil-works/pi-schema 提供。它的包描述只有一句:
JSON Schema validation without eval: drafts 2020-12 and 7, compiled into plain closures
译成中文:不使用 eval 的 JSON Schema 校验,支持 draft 2020-12 与 draft 7,把 schema 编译成普通闭包。
"不用 eval" 这一条对处理不可信来源的 schema 很关键——MCP 服务发来的工具参数 schema 属于外部输入,用 eval 编译它等于开了代码执行的口子。编译成闭包没有这个问题。
它提供两套 API:
先定义接收规则
本例要求五个字段全部存在,不允许额外字段;函数名、摘要和证据不能为空;参数是字符串数组;不确定的空输入行为允许为 null。
"字段允许为空"和"字段可以省略"是两件事。这里选择始终提供 emptyInputBehavior,未知时显式填 null,让消费代码能够区分未知和漏字段。
创建 src/schema.ts:
import { arr, compile, nullable, obj, type Static, str } from "@earendil-works/pi-schema"
import { sourceCode } from "./lesson.js"
export const analysisSchema = obj({
name: str({ minLength: 1, maxLength: 80 }),
parameters: arr(str({ minLength: 1, maxLength: 80 }), { maxItems: 20 }),
summary: str({ minLength: 1, maxLength: 1000 }),
emptyInputBehavior: nullable(str({ minLength: 1, maxLength: 500 })),
evidence: str({ minLength: 1, maxLength: 500 }),
})
export type FunctionAnalysis = Static<typeof analysisSchema>
const validator = compile(analysisSchema)
export class ResultValidationError extends Error {
constructor(
readonly stage: "schema" | "business",
message: string,
) {
super(message)
}
}
export function validateAnalysis(value: unknown): FunctionAnalysis {
if (!validator.check(value)) {
throw new ResultValidationError("schema", JSON.stringify(validator.errors(value)))
}
// 只检查本练习能明确验证的引用关系,不宣称完成了任意代码语义证明。
if (!sourceCode.includes(`function ${value.name}(`)) {
throw new ResultValidationError("business", "函数名与练习源码不一致")
}
if (!sourceCode.includes(value.evidence) || !value.evidence.trim()) {
throw new ResultValidationError("business", "证据不是练习源码中的有效片段")
}
return value
}obj() 默认构建封闭对象——未声明的字段会被拒绝。它的实现里写得很直接:
return {
type: "object",
properties: props,
...(required.length > 0 ? { required } : {}),
...(open === true ? {} : { additionalProperties: false }),
...annotations,
};只有显式传 { open: true } 才允许额外属性。arr() 限制数组,nullable() 表达空值。Static 从同一份 Schema 推导 TypeScript 类型,避免维护第二份手写接口。
compile() 创建校验器,validator.check() 是实际运行时检查。这里没有启用类型转换:"items" 不会偷偷变成数组,字段错误应该明确暴露出来。
跑一份合法样本
在 fixtures/valid-analysis.json 中保存:
{
"name": "first",
"parameters": ["items"],
"summary": "返回数组的第一个元素",
"emptyInputBehavior": "返回 undefined",
"evidence": "return items[0]"
}创建 src/validate-output.ts:
import { readFile } from "node:fs/promises"
import { validateAnalysis } from "./schema.js"
const path = process.argv[2]
const text = path
? await readFile(path, "utf8")
: await readFile(new URL("../fixtures/valid-analysis.json", import.meta.url), "utf8")
const value: unknown = JSON.parse(text)
const analysis = validateAnalysis(value)
console.log("已通过字段与源码引用检查:", analysis.name)
console.log("空输入说明:", analysis.emptyInputBehavior ?? "需要人工确认")运行本地样本:
node --import tsx src/validate-output.ts再检查模型实际生成的文件:
node --import tsx src/validate-output.ts analysis.json只有 validateAnalysis() 返回后,代码才把对象当作 FunctionAnalysis 使用。类型断言不能代替这个入口。
错误消息长什么样
validator.errors(value) 返回一组 ValidationError:
export interface ValidationError {
readonly path: string;
readonly keyword: string;
readonly message: string;
}path 是 JSON Pointer("" 表示值本身)。模型层把它转成点号路径,格式是 - 路径: 消息:
模型层拼出的完整消息(来自 packages/ai/src/utils/validation.ts):
Validation failed for tool "read_file":
- limit: expected number, got string
Received arguments:
{
"path": "a.txt",
"limit": "ten"
}末尾的 Received arguments 是模型原始发来的内容,不是转换后的值。
强制转换:校验前会发生什么
工具参数校验用的是带转换的编译选项:
const validator = compile(schema, { formats, coerce: true, unresolvedRefs: "reject" });coerce: true 意味着校验之前先跑一轮容错转换。三趟,按顺序:
第一趟——丢弃可选属性里的 null。 一个可选属性(不在 required 里)的值是 null、而它的 schema 不接受 null 时,这个 null 被当作"没提供"丢弃。
第二趟——按 schema 结构做转换。 规则移植自 TypeBox 1.3.27 的 Value.Convert,按 JSON Schema 的关键字(不是 TypeBox 的 kind 标记)决定:
string:数字和布尔转文本,null转"null"number:数字字符串(""是 0)、"true"/"false"、布尔转 1/0、null转 0integer:同number,然后截断("3.5"和3.5都变 3)boolean:"true"/"false"(任意大小写)、"1"/"0"、1/0、null转falsenull:"null"、"undefined"、""、"0"、0 和false- 数组:单值包成一元素数组(
items不是数组时) enum:取第一个能被当前值转换到的成员anyOf/oneOf:值被某个分支接受就保留,否则取第一个让整体接受的分支的转换结果
第三趟——按 type 做原始类型强制转换。 类似 Ajv 的 coerceTypes,处理 ["integer", "null"] 这样的类型数组,以及 "" → null。
实测几个常见情况:
这直接影响你的业务代码:执行器拿到的参数不一定等于模型发的 JSON。如果业务要求"数字必须是数字、不能是字符串",那就不能依赖 schema 校验——它已经帮你转过了。需要在 execute() 里再检查原始值,或者在工具的 prepareArguments 里处理。
转换是 copy-on-write 的:只有变了的路径会被复制,什么都没变时返回同一个引用,输入永远不会被修改。
TypeBox 的哪些特性不生效
TypeBox schema 本身是 JSON Schema,可以直接编译。但 TypeBox 超出 JSON Schema 的那些特性不会执行。
具体的验证方式:
import { compile } from "@earendil-works/pi-schema";
import { formats } from "@earendil-works/pi-schema/formats";
import Type from "typebox";
// Type.Refine 的守卫不会跑
const refined = compile(
Type.Refine(Type.Object({ age: Type.Number() }), (x) => x.age > 0),
{ formats, coerce: true },
);
console.log(refined.parse({ age: -1 })); // { ok: true, value: { age: -1 } }结论:所有业务约束都要在 execute() 里自己检查。Type.Refine() 写了不等于生效了。
三个"拒绝一切值"的类型
Type.BigInt()、Type.Undefined()、Type.Void() 编译出来的 schema 分别是 {"type":"bigint"}、{"type":"undefined"}、{"type":"void"}。这三个类型名不在受支持的类型集合里,所以任何值都通不过:
Type.BigInt() → { "type": "bigint" } 1n => expected bigint, got bigint
Type.Undefined() → { "type": "undefined" } undef => expected undefined, got undefined
Type.Void() → { "type": "void" } undef => expected void, got undefined注意第一行的错误消息读起来很怪——"期望 bigint,得到 bigint"。原因是类型名不被识别时检查直接判否,而错误消息里的实际类型名是按值的 typeof 推出来的。看到这种自相矛盾的消息,就知道问题出在类型名上,而不是值上。
有一个版本边界要注意:仓库主分支(commit 6d3e342b)已经支持 bigint 和 undefined 两个类型,这是 v1.5.0 发布标签之后合入的 "TypeBox parity for extra types" 改动。但 npm 上发布的 @earendil-works/pi-schema@1.5.0 还没有这两个类型。本文以 npm 发布版本为准描述行为;如果你从仓库主分支构建或使用更新的版本,bigint 和 undefined 会按值正常通过。
无论哪个版本,void 都不受支持。
自定义格式同理。typebox/format 的 Format.Set() 注册的格式,pi-schema 不知道;只有通过 formats 选项传进来的那 21 个会被检查:
date, date-time, duration, email, hostname, idn-email, idn-hostname, ipv4, ipv6,
iri, iri-reference, json-pointer, json-pointer-uri-fragment, regex,
relative-json-pointer, time, uri, uri-reference, uri-template, url, uuid这是 draft 2020-12 定义的全部格式,加上 url 和 json-pointer-uri-fragment 两个 TypeBox 和 ajv-formats 也检查的。
悬空 $ref 只拒绝那一处
unresolvedRefs 选项控制"指向不存在定义的 $ref"怎么办:
export interface CompileOptions {
readonly draft?: "2020-12" | "7";
readonly formats?: Readonly<Record<string, FormatCheck>>;
readonly schemas?: Readonly<Record<string, JsonSchema>>;
readonly coerce?: boolean;
/**
* What a `$ref` that names nothing (a missing definition, a document not in `schemas`) does:
* `"throw"` (default) makes compile() throw; `"reject"` compiles it as `false`, so only values
* at that place fail, with the error "unresolved reference".
*/
readonly unresolvedRefs?: "throw" | "reject";
}默认 "throw" 让 compile() 直接抛错。"reject" 把这个引用编译成 false——只有该位置的值失败,其它部分照常工作:
{"path": "/a", "keyword": "$ref", "message": "unresolved reference #/$defs/missing"}工具参数校验用 "reject",因为 MCP 服务发来的 schema 可能有悬空定义,不该让整个工具的每次调用都失败。
分别验证结构和业务关系
把 parameters 改成字符串,应在 Schema 阶段失败。增加未定义的 debug 字段也应失败。把 evidence 改成源码里没有的 return null,应在业务阶段失败。
这两层检查的意义不同:Schema 能验证字段形状,业务检查把数据重新联系到任务输入。错误阶段保存在 ResultValidationError.stage 中,后续可以据此决定怎样报告或纠正。
本例的业务检查仅确认函数名和证据与这份固定源码有引用关系,不会证明所有解释都正确。即使证据来自源码,模型仍可能误解它的含义;参数名、自然语言摘要和空数组语义仍需要对照样本检查,不能把字符串包含关系包装成完整静态分析。
允许未知,避免伪造确定性
将 emptyInputBehavior 改为 null,检查仍应通过,消费程序会显示"需要人工确认"。若业务不允许未知,就应在业务层阻止自动处理,而不是要求模型无论如何填写一个答案。
数组大小和字符串长度限制用于控制这个练习的接收范围。以后输入多函数文件时,应该重新设计数据契约,而不是直接放宽到无限大小。
完成后,你已经能拦截一批"语法正确、数据错误"的输出。下一篇通过工具调用提交结果,把结果提交动作变成明确的程序入口。
本篇的 schema、错误文案与类型转换行为都用仓库源码实测过,本轮未与真实模型联调;真实模型是否按 schema 生成,仍需在自己的账号上验证。