跳到主内容
创建时间:最后更新:作者:白川

用 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:

API用途是否做类型转换
compile(schema) → Validator
只检查
否
compile(schema, { coerce: true }) → CoercingValidator
检查 + 转换
是

先定义接收规则

本例要求五个字段全部存在,不允许额外字段;函数名、摘要和证据不能为空;参数是字符串数组;不确定的空输入行为允许为 null。

"字段允许为空"和"字段可以省略"是两件事。这里选择始终提供 emptyInputBehavior,未知时显式填 null,让消费代码能够区分未知和漏字段。

创建 src/schema.ts:

typescript
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() 默认构建封闭对象——未声明的字段会被拒绝。它的实现里写得很直接:

typescript
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 中保存:

json
{
  "name": "first",
  "parameters": ["items"],
  "summary": "返回数组的第一个元素",
  "emptyInputBehavior": "返回 undefined",
  "evidence": "return items[0]"
}

创建 src/validate-output.ts:

typescript
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 ?? "需要人工确认")

运行本地样本:

bash
node --import tsx src/validate-output.ts

再检查模型实际生成的文件:

bash
node --import tsx src/validate-output.ts analysis.json

只有 validateAnalysis() 返回后,代码才把对象当作 FunctionAnalysis 使用。类型断言不能代替这个入口。

错误消息长什么样

validator.errors(value) 返回一组 ValidationError:

typescript
export interface ValidationError {
	readonly path: string;
	readonly keyword: string;
	readonly message: string;
}

path 是 JSON Pointer("" 表示值本身)。模型层把它转成点号路径,格式是 - 路径: 消息:

失败情形错误消息
缺少必填字段
- limit: missing
类型不符
- limit: expected number, got string
枚举不匹配
- mode: expected one of ["a","b"]
未知字段
- debug: unknown field
格式不符
- e: expected format email
悬空 $ref
- a: unresolved reference #/$defs/missing

模型层拼出的完整消息(来自 packages/ai/src/utils/validation.ts):

text
Validation failed for tool "read_file":
  - limit: expected number, got string

Received arguments:
{
  "path": "a.txt",
  "limit": "ten"
}

末尾的 Received arguments 是模型原始发来的内容,不是转换后的值。

强制转换:校验前会发生什么

工具参数校验用的是带转换的编译选项:

typescript
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 转 0
  • integer:同 number,然后截断("3.5" 和 3.5 都变 3)
  • boolean:"true"/"false"(任意大小写)、"1"/"0"、1/0、null 转 false
  • null:"null"、"undefined"、""、"0"、0 和 false
  • 数组:单值包成一元素数组(items 不是数组时)
  • enum:取第一个能被当前值转换到的成员
  • anyOf/oneOf:值被某个分支接受就保留,否则取第一个让整体接受的分支的转换结果

第三趟——按 type 做原始类型强制转换。 类似 Ajv 的 coerceTypes,处理 ["integer", "null"] 这样的类型数组,以及 "" → null。

实测几个常见情况:

输入schema转换后
"42"
{ type: "number" }
42
"true"
{ type: "boolean" }
true
"x"
{ type: "array", items: { type: "string" } }
["x"]
null
可选属性 { type: "string" }
该属性被删除

这直接影响你的业务代码:执行器拿到的参数不一定等于模型发的 JSON。如果业务要求"数字必须是数字、不能是字符串",那就不能依赖 schema 校验——它已经帮你转过了。需要在 execute() 里再检查原始值,或者在工具的 prepareArguments 里处理。

转换是 copy-on-write 的:只有变了的路径会被复制,什么都没变时返回同一个引用,输入永远不会被修改。

TypeBox 的哪些特性不生效

TypeBox schema 本身是 JSON Schema,可以直接编译。但 TypeBox 超出 JSON Schema 的那些特性不会执行。

TypeBox 特性在 pi-schema 下的行为
Type.Refine(schema, guard)
guard 不执行。约束只存在于非枚举属性 ~refine 上,不参与校验
Type.Codec(...)
不解码。execute() 收到的是 JSON 值,不是解码后的值
Type.BigInt()
拒绝一切值(bigint 不是受支持的类型名)
Type.Undefined()
拒绝一切值(undefined 不是受支持的类型名)
Type.Void()
拒绝一切值(void 不是受支持的类型名)
Format.Set() 注册的自定义格式
不检查。只有内置的 21 个标准格式会被检查

具体的验证方式:

typescript
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"}。这三个类型名不在受支持的类型集合里,所以任何值都通不过:

text
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 个会被检查:

text
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"怎么办:

typescript
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——只有该位置的值失败,其它部分照常工作:

text
{"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 生成,仍需在自己的账号上验证。

参考资料