第五章:类型系统 -- TypeBox 与 Schema 驱动开发
"好的类型系统不是限制你,而是替你思考。"
5.1 为什么需要 Schema?
当 LLM 要调用一个工具时,它需要知道:
- 工具叫什么名字
- 工具接受什么参数
- 每个参数的类型和含义
这些信息就是 Schema——参数的"合同"。
在 Java 中,你可能用过 Jackson 的 @JsonProperty 注解或 Hibernate Validator 来描述数据结构。TypeScript 世界中,TypeBox 做的是同样的事,但更强大——它用代码定义 JSON Schema,同时自动推导出 TypeScript 类型。
📌 源码定位 ───────────────────────────────────── TypeBox 的使用遍布整个项目:
packages/agent/src/types.ts--AgentTool<TParameters extends TSchema>packages/coding-agent/src/core/tools/*.ts-- 每个工具的参数 Schemapackages/coding-agent/src/core/extensions/types.ts--ToolDefinition<TParams>─────────────────────────────────────────────────
5.2 TypeBox 基础
import { Type, Static } from "typebox";
// 定义 Schema(运行时可用,发送给 LLM)
const readSchema = Type.Object({
path: Type.String({ description: "Absolute or relative path to the file" }),
offset: Type.Optional(Type.Number({ description: "Line number to start reading from" })),
limit: Type.Optional(Type.Number({ description: "Number of lines to read" })),
});
// 从 Schema 推导 TypeScript 类型(编译时可用)
type ReadInput = Static<typeof readSchema>;
// 等价于:
// type ReadInput = {
// path: string;
// offset?: number;
// limit?: number;
// }⚡ Java 对照 ─────────────────────────────────────
| TypeBox | Java 等价 |
|---|---|
Type.Object({path: Type.String()}) | class ReadInput { String path; } + @JsonSchema |
Type.Optional(Type.Number()) | @Nullable Integer offset |
Type.String({description: "..."}) | @Schema(description = "...") |
Static<typeof schema> | 编译时生成的类型(无直接等价) |
─────────────────────────────────────────────────
5.3 Schema 的双重生命
TypeBox 的核心价值在于一个定义,两种用途:
const echoSchema = Type.Object({
message: Type.String({ description: "Message to echo" }),
});
// 用途 1:编译时类型安全(给开发者用)
type EchoInput = Static<typeof echoSchema>;
function processInput(input: EchoInput) {
console.log(input.message); // TypeScript 知道 message 是 string
}
// 用途 2:运行时 JSON Schema(给 LLM 用)
// echoSchema 的值就是标准 JSON Schema:
// {
// type: "object",
// properties: {
// message: { type: "string", description: "Message to echo" }
// },
// required: ["message"]
// }★ Insight ─────────────────────────────────────这就是"单一事实来源"(Single Source of Truth)原则。 在 Java 中,你可能需要同时维护一个 POJO 类和一个 JSON Schema 文件,它们之间没有自动同步机制。TypeBox 让你只写一次定义,同时得到类型安全的 TypeScript 类型和 LLM 能理解的 JSON Schema。如果你改了 Schema,TypeScript 类型自动更新,编译器会告诉你哪里出了问题。 ─────────────────────────────────────────────────
5.4 工具定义的完整模式
让我们以 Pi 的 read 工具为例,看完整的定义模式:
// packages/coding-agent/src/core/tools/read.ts(简化)
import { Type, type Static } from "typebox";
// Step 1: 定义 Schema
const readSchema = Type.Object({
path: Type.String({
description: "Absolute or relative path to the file to read"
}),
offset: Type.Optional(Type.Number({
description: "Line number to start reading from. Only provide if the file is too large to read at once.",
minimum: 0
})),
limit: Type.Optional(Type.Number({
description: "Number of lines to read. Only provide if the file is too large to read at once.",
exclusiveMinimum: 0
})),
});
// Step 2: 推导输入类型
type ReadToolInput = Static<typeof readSchema>;
// Step 3: 定义结果详情类型(给 UI 用)
interface ReadToolDetails {
truncation?: { startLine: number; endLine: number; totalLines: number };
}
// Step 4: 定义操作接口(依赖注入,用于测试和远程文件系统)
interface ReadOperations {
readFile: (absolutePath: string) => Promise<Buffer>;
access: (absolutePath: string) => Promise<void>;
}
// Step 5: 创建工具定义
function createReadToolDefinition(
cwd: string,
options?: { operations?: ReadOperations }
): ToolDefinition<typeof readSchema, ReadToolDetails | undefined> {
const ops = options?.operations ?? defaultOperations;
return {
name: "read",
label: "Read",
description: "Read a file from disk",
parameters: readSchema, // Schema 发送给 LLM
async execute(toolCallId, params, signal, onUpdate) {
// params 的类型是 Static<typeof readSchema> = ReadToolInput
// TypeScript 自动提供类型安全!
const absolutePath = path.resolve(cwd, params.path);
await ops.access(absolutePath);
const buffer = await ops.readFile(absolutePath);
const content = buffer.toString("utf-8");
return {
content: [{ type: "text", text: content }],
details: { truncation: { ... } },
};
},
};
}★ Insight ─────────────────────────────────────5 步定义模式是 Pi 中所有工具的标准写法:Schema → 输入类型 → 详情类型 → 操作接口 → 工具定义。这种模式的好处是每一层都可以独立测试和替换。ReadOperations 接口让你可以注入一个远程文件系统的实现,而不需要修改工具的核心逻辑。这就是依赖注入在函数式风格下的表现形式。 ─────────────────────────────────────────────────
5.5 TypeBox 类型速查表
Pi 中常用的 TypeBox 类型:
// 基础类型
Type.String() // string
Type.Number() // number
Type.Boolean() // boolean
Type.Null() // null
// 带约束
Type.String({ minLength: 1 }) // string & length >= 1
Type.Number({ minimum: 0 }) // number & >= 0
Type.String({ pattern: "^\\d+$" }) // 正则约束
// 可选
Type.Optional(Type.String()) // string | undefined
// 数组
Type.Array(Type.String()) // string[]
Type.Array(Type.Object({...})) // {name: string}[]
// 对象
Type.Object({
name: Type.String(),
age: Type.Optional(Type.Number()),
})
// 联合
Type.Union([Type.String(), Type.Number()]) // string | number
// 字面量
Type.Literal("read") // "read" (字面量类型)⚡ Java 对照 ─────────────────────────────────────
| TypeBox | Java Bean Validation |
|---|---|
Type.String({minLength: 1}) | @NotBlank |
Type.Number({minimum: 0}) | @Min(0) |
Type.Optional(...) | @Nullable |
Type.Array(...) | List<...> |
Type.Object({...}) | @Data class |
─────────────────────────────────────────────────
5.6 validateToolArguments:运行时验证
TypeBox 不仅定义类型,还能在运行时验证数据:
// packages/ai 中的验证函数
import { validateToolArguments } from "@mariozechner/pi-ai";
const tool = { parameters: readSchema, ... };
const rawArgs = { path: "/etc/passwd", offset: -1 }; // 可能来自 LLM 的不可信输入
// 运行时验证(会检查 minimum: 0 约束)
const validated = validateToolArguments(tool, { id: "call_1", name: "read", arguments: rawArgs });
// 如果 offset 不满足 minimum: 0,会抛出错误★ Insight ─────────────────────────────────────为什么要运行时验证? 因为 LLM 的输出是不可信的。LLM 可能返回不合法的参数(如负数行号、不存在的路径)。TypeBox 的 Schema 验证就像 Java 的 Bean Validation——在数据进入业务逻辑之前拦截非法值。这是防御性编程的关键一环。 ─────────────────────────────────────────────────
5.7 泛型约束:TSchema 的作用
为什么工具定义要用 TParameters extends TSchema 而不是直接用 any?
// 这样写:泛型约束确保类型安全
interface AgentTool<TParameters extends TSchema> {
parameters: TParameters;
execute: (params: Static<TParameters>) => Promise<AgentToolResult>;
}
// 使用时:
const readTool: AgentTool<typeof readSchema> = { ... };
readTool.execute({ path: "/etc/passwd" }); // OK
readTool.execute({ path: 123 }); // 编译错误!path 必须是 string
readTool.execute({ foo: "bar" }); // 编译错误!没有 foo 字段如果用 any 代替 TSchema,你将失去所有类型检查。泛型约束在这里的作用就像 Java 的 <T extends Comparable<T>>——它让编译器帮你检查正确性。
5.8 satisfies 操作符
Pi 中一个常见的模式是使用 satisfies:
const DEFAULT_MODEL = {
id: "unknown",
name: "unknown",
api: "unknown",
provider: "unknown",
baseUrl: "",
reasoning: false,
input: [],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 0,
maxTokens: 0,
} satisfies Model<any>;satisfies 和 as 的区别:
as Model<any>-- 强制转换,可能隐藏错误satisfies Model<any>-- 检查是否满足类型,但保留原始字面量类型
★ Insight ─────────────────────────────────────satisfies 是 TypeScript 4.9 引入的,是类型安全的"验证但不转换"。 在 Java 中没有直接等价物。它相当于说:"请验证这个对象满足 Model<any> 的所有要求,但不要忘记它具体的字段值。" 这样你后续访问 DEFAULT_MODEL.id 时,TypeScript 知道它的值是 "unknown" 而不仅仅是 string。 ─────────────────────────────────────────────────
5.9 本章小结
TypeBox 在 Pi 中扮演的角色:
定义 Schema → Type.Object({ ... })
│
├── 编译时 → Static<typeof schema> (TypeScript 类型)
│ 用于代码中的类型检查
│
└── 运行时 → schema 本身 (JSON Schema)
├── 发送给 LLM(工具参数描述)
└── 验证 LLM 返回的参数核心价值:一个定义,两种用途,零重复。