本篇文章是《从零开发一个 Coding Agent》系列第五篇。在上一篇中,我们使用状态机校验了大模型事件流,确保 tool_call_start、tool_call_delta 和 tool_call_end 的顺序正确,并且最终参数与流式传输的 JSON 一致。
但“它是一个合法 JSON 对象”不等于“它符合工具要求”。例如 read 工具要求 limit 是数字,模型却可能返回:
{
"path": "README.md",
"limit": "20"
}
这段 JSON 语法正确,根节点也是对象,但是字符串 "20" 不能直接当作数字使用。如果不在工具执行前拦截,错误就会进入文件读取、命令执行等真正有副作用的代码。
这一篇将使用 TypeBox 为每个工具定义参数结构,并实现两个校验入口:
模型返回的 JSON 字符串 -> parseToolArguments()
已经解析出的未知对象 -> validateToolArguments()
|
v
ToolDefinition.parameters
|
通过 -> 有准确类型的参数
失败 -> ToolArgumentsValidationError
先了解 ai 模块中的相关类型
工具参数校验位于 packages/ai,因为这里定义的是 Provider、Agent 和具体工具共同遵守的基础契约。先打开:
packages/ai/src/types.ts
本篇直接相关的第一个类型是 ToolCallContent:
export interface ToolCallContent {
type: "tool_call";
id: string;
name: string;
arguments: Record<string, unknown>;
}
它表示模型已经生成了一次完整工具调用:
id是本次调用的唯一标识,后续工具结果要通过它与调用对应。name是模型希望调用的工具名称,例如read。arguments是模型提供的参数。
这里把 arguments 写成 Record<string, unknown>,而不是某个具体工具的参数类型,是因为 ToolCallContent 需要同时表示 read、write、bash 等不同工具。此时只能确定它是一个键值对象,还不能确定每个字段是否正确。
具体工具对参数的要求由 ToolDefinition 描述:
import type { TSchema } from "typebox";
export interface ToolDefinition<TParameters extends TSchema = TSchema> {
name: string;
description: string;
parameters: TParameters;
}
TParameters 是一个泛型参数,表示当前工具自己的 TypeBox schema。泛型让不同工具保留各自的参数结构,而不是全部退化成模糊的 TSchema。
工具定义会通过 Context.tools 交给 Provider:
export interface Context {
systemPrompt?: string;
messages: Message[];
tools?: ToolDefinition[];
}
Provider 把这些工具描述发送给大模型。模型选择工具后,通过下面三种流事件逐步返回调用信息:
| { type: "tool_call_start"; contentIndex: number; id: string; name: string }
| { type: "tool_call_delta"; contentIndex: number; argumentsDelta: string }
| { type: "tool_call_end"; contentIndex: number; toolCall: ToolCallContent }
因此,这条数据链可以概括为:
ToolDefinition.parameters
-> Provider 告诉模型工具需要哪些参数
-> 模型返回 ToolCallContent.arguments
-> 参数校验器按同一份 parameters schema 检查
-> 通过后才允许进入后续工具执行流程
TypeBox 是什么
TypeScript 类型只在编译期间存在。下面这句类型断言不会检查运行时数据:
const argumentsValue = externalValue as { path: string; limit: number };
如果 externalValue.limit 实际是字符串,TypeScript 也不会帮我们转换或拒绝它。
TypeBox 是一个用于构建 JSON Schema 的 TypeScript 库。它的特点是同一份 schema 可以同时服务于两个阶段:
- 编译阶段:推导出 TypeScript 类型。
- 运行阶段:检查真正收到的 JavaScript 值。
例如定义一个简化的 read 工具:
import { Type } from "typebox";
import type { Static } from "typebox";
const readParameters = Type.Object({
path: Type.String({ minLength: 1 }),
limit: Type.Number({ minimum: 1 }),
});
type ReadArguments = Static<typeof readParameters>;
readParameters 在运行时是一个 JSON Schema 对象。Static<typeof readParameters> 则会在编译期得到下面的类型:
type ReadArguments = {
path: string;
limit: number;
};
常用构造器包括:
| TypeBox 写法 | 表示的参数 |
|---|---|
Type.String() | 字符串 |
Type.Number() | 数字 |
Type.Boolean() | 布尔值 |
Type.Array(Type.String()) | 字符串数组 |
Type.Optional(...) | 可选字段 |
Type.Object({ ... }) | 对象及其字段 |
还可以通过选项表达约束。例如 minLength: 1 表示字符串不能为空,minimum: 1 表示数字不能小于 1。
本项目安装的是固定版本 typebox@1.1.38。运行时校验器从 typebox/compile 导入:
import { Compile } from "typebox/compile";
const validator = Compile(readParameters);
validator.Check({ path: "README.md", limit: 20 }); // true
validator.Check({ path: "README.md", limit: "20" }); // false
本项目只使用严格检查,不使用隐式类型转换。也就是说,schema 要求数字时,字符串 "20" 会被拒绝,而不会悄悄变成数字 20。这样能够尽早暴露模型或 Provider 的协议错误。
定义工具参数错误
继续修改上一篇创建的文件:
packages/ai/src/utils/validation.ts
这个文件前半部分已经包含事件流状态机。本篇代码追加在 createStreamEventValidator() 后面,不需要修改原来的状态机。
先定义一个专用错误类型:
/** 表示一组工具参数无法安全地满足该工具的 TypeBox schema。 */
export class ToolArgumentsValidationError extends Error {
readonly toolName: string;
readonly issues: readonly string[];
constructor(toolName: string, issues: readonly string[]) {
const details = issues.map((issue) => ` - ${issue}`).join("\n");
super(`Invalid arguments for tool "${toolName}":\n${details}`);
this.name = "ToolArgumentsValidationError";
this.toolName = toolName;
this.issues = [...issues];
}
}
这个错误保留两类结构化信息:
toolName:哪个工具的参数不合法。issues:具体有哪些字段或约束失败。
例如 read 缺少 path 时,最终错误类似:
Invalid arguments for tool "read":
- /path: must have required properties path
错误消息不应该附带整个原始参数。工具参数未来可能包含长文本、用户输入甚至敏感信息;只保留工具名、字段路径和失败原因,更适合传递和记录。
格式化 TypeBox 错误路径
在文件顶部增加本篇需要的 import:
import type { Static, TSchema } from "typebox";
import { Compile } from "typebox/compile";
import type { TLocalizedValidationError } from "typebox/error";
import type { AssistantContent, StreamEvent, ToolDefinition } from "../types.ts";
其中:
TSchema表示任意 TypeBox schema。Static<TSchema>用于得到 schema 对应的 TypeScript 类型。Compile()把 schema 编译成运行时校验器。TLocalizedValidationError是 TypeBox 返回的错误对象类型。
TypeBox 使用 JSON Pointer(JSON 指针)表示字段位置,例如:
/path
/options/labels/0
JSON Pointer 规定,字段名中的 ~ 要写成 ~0,/ 要写成 ~1。先加入转义函数:
function escapeJsonPointerSegment(segment: string): string {
return segment.replace(/~/g, "~0").replace(/\//g, "~1");
}
然后统一格式化 TypeBox 的错误:
function formatValidationIssue(error: TLocalizedValidationError): string {
if (error.keyword === "required") {
const requiredProperty = error.params.requiredProperties[0];
if (requiredProperty) {
const segment = escapeJsonPointerSegment(requiredProperty);
return `${error.instancePath}/${segment}: ${error.message}`;
}
}
const path = error.instancePath || "/";
return `${path}: ${error.message}`;
}
缺少必填字段时,字段本身还不存在,所以 TypeBox 的 instancePath 通常只指向它的父对象,缺失的字段名位于 requiredProperties 中。这里把两部分拼起来,确保根对象缺少 path 时报告 /path,而不是含糊的根路径 /。
先限制参数根节点
TypeBox schema 负责检查具体字段。在此之前,我们先建立一个更基础的边界:工具参数的根节点必须是普通对象。
function isPlainObject(value: unknown): value is Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return false;
}
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
这个函数会拒绝:
null- 数组
DateMap- 其他拥有特殊原型的实例
模型工具参数应当是一个普通的 JSON 风格对象,而不是带有方法或隐藏行为的类实例。
接着创建一份深拷贝:
function cloneToolArguments(toolName: string, value: unknown): Record<string, unknown> {
if (!isPlainObject(value)) {
throw new ToolArgumentsValidationError(toolName, ["/: arguments must be a plain object"]);
}
try {
return structuredClone(value);
} catch {
throw new ToolArgumentsValidationError(toolName, [
"/: arguments must contain only structured-clone-compatible values",
]);
}
}
为什么要在校验前调用 structuredClone()?看下面的例子:
const input = { path: "README.md", limit: 20 };
const validated = validateToolArguments(readTool, input);
input.limit = -1;
如果 validated 和 input 指向同一个对象,调用方在校验后仍能把它改成非法值。先克隆再检查,可以让返回值与外部可变引用分离。
如果对象中含有函数等无法克隆的值,structuredClone() 会抛出原生异常。这里把它统一转换为 ToolArgumentsValidationError,避免上层需要识别不同平台的原生错误。
需要注意,克隆不是权限控制。它不会判断文件路径是否越界,也不会限制命令、超时或副作用;这些规则属于以后具体工具和 Agent Loop 的职责。
实现对象参数校验
现在实现第一个公共入口 validateToolArguments():
export function validateToolArguments<TParameters extends TSchema>(
tool: ToolDefinition<TParameters>,
value: unknown,
): Static<TParameters> {
const candidate = cloneToolArguments(tool.name, value);
const validator = Compile(tool.parameters);
if (validator.Check(candidate)) {
return candidate;
}
const issues = validator.Errors(candidate).map(formatValidationIssue);
throw new ToolArgumentsValidationError(
tool.name,
issues.length > 0 ? issues : ["/: arguments do not satisfy the tool schema"],
);
}
它的处理顺序是:
- 接受
unknown,不提前相信外部数据。 - 确认根节点是普通对象,并创建深拷贝。
- 使用
Compile(tool.parameters)创建校验器。 - 使用
Check(candidate)执行严格校验。 - 失败时收集并格式化全部问题。
- 成功时返回
Static<TParameters>。
Check() 不只是返回布尔值,它还是一个类型守卫。在 if 的成功分支中,TypeScript 能确认 candidate 符合当前 schema,因此不需要写下面这种危险断言:
return candidate as Static<TParameters>;
这正是 TypeBox 的价值:运行时检查与编译期类型由同一份 schema 连接起来。
这里也没有捕获 Compile(tool.parameters) 自己的错误。无效 schema 是开发者配置错误,不是模型参数错误;两者应保持不同的错误语义。
实现 JSON 字符串入口
工具参数在流式传输过程中以 JSON 字符串片段到达。上一篇会在 tool_call_end 时把完整字符串解析成对象,不过其他 Provider 适配器也可能直接拿到完整 JSON 字符串,因此再提供一个统一入口:
export function parseToolArguments<TParameters extends TSchema>(
tool: ToolDefinition<TParameters>,
json: string,
): Static<TParameters> {
let value: unknown;
try {
value = JSON.parse(json) as unknown;
} catch {
throw new ToolArgumentsValidationError(tool.name, ["/: arguments must be valid JSON"]);
}
return validateToolArguments(tool, value);
}
这个函数只增加一层 JSON 语法处理,解析成功后立即复用 validateToolArguments()。这样两个入口共享完全相同的根对象限制、schema 规则、克隆行为和错误格式。
JSON.parse() 在 TypeScript 的标准声明中返回 any。这里使用 as unknown 不是声称数据已经合法,而是把不安全的 any 收紧回 unknown,强制它继续经过后面的运行时校验。
完整数据流现在是:
flowchart LR
Json["模型参数 JSON"] --> Parse["JSON.parse"]
Object["已有参数对象"] --> Clone["普通对象检查与深拷贝"]
Parse --> Clone
Clone --> Compile["Compile(parameters)"]
Compile --> Check{"Check(candidate)"}
Check -->|通过| Typed["Static"]
Check -->|失败| Issues["Errors(candidate)"]
Issues --> Error["ToolArgumentsValidationError"]
从 ai 包入口导出
最后修改:
packages/ai/src/index.ts
保留原有导出,并把三个新符号加入 validation.ts 的公共导出:
export {
createStreamEventValidator,
parseToolArguments,
StreamSequenceError,
ToolArgumentsValidationError,
validateToolArguments,
} from "./utils/validation.ts";
formatValidationIssue()、isPlainObject() 和 cloneToolArguments() 都是内部实现细节,不需要从包入口暴露。
项目入口已经重新导出了 TypeBox 的常用符号:
export type { Static, TSchema } from "typebox";
export { Type } from "typebox";
这样以后定义具体工具时,可以统一从 @di-code/ai 获取 schema 构造器和公共类型,不必依赖 ai 包内部文件路径。
定义并校验一个工具
下面用一个简化的 read 工具串起前面的类型和函数:
import {
parseToolArguments,
Type,
type ToolDefinition,
validateToolArguments,
} from "@di-code/ai";
const parameters = Type.Object({
path: Type.String({ minLength: 1 }),
limit: Type.Number({ minimum: 1 }),
options: Type.Object({
labels: Type.Array(Type.String()),
}),
});
const readTool = {
name: "read",
description: "Read a text file",
parameters,
} satisfies ToolDefinition<typeof parameters>;
satisfies 会检查对象符合 ToolDefinition,同时保留 parameters 的具体 schema 类型。于是校验成功后的返回值也会保留准确字段:
const fromObject = validateToolArguments(readTool, {
path: "README.md",
limit: 20,
options: { labels: ["docs"] },
});
const fromJson = parseToolArguments(
readTool,
'{"path":"README.md","limit":20,"options":{"labels":["docs"]}}',
);
console.log(fromObject.path); // string
console.log(fromJson.limit); // number
如果 limit 是字符串、path 为空、必填字段缺失,或者 JSON 语法错误,入口都会抛出 ToolArgumentsValidationError,而不会把问题参数交给后续执行层。
总结
这一篇为工具调用增加了第二层校验边界:
- 上一篇的状态机保证工具调用事件顺序正确,流式 JSON 前后一致。
- 本篇的 TypeBox 校验保证完整参数满足具体工具的字段和约束。
ToolDefinition<TParameters>保存 schema 的具体类型。Static<TParameters>把校验成功的结果变成准确的 TypeScript 类型。validateToolArguments()处理已经解析的未知对象。parseToolArguments()处理完整 JSON 字符串,并复用同一套对象校验。ToolArgumentsValidationError提供稳定、可识别且不泄漏原始参数的错误信息。
当前我们只是建立了参数安全边界,还没有真正查找和执行工具。后续实现 Agent 工具调用闭环时,Agent 会先根据名称找到 ToolDefinition,再调用本篇的校验函数,最后才把参数交给具体工具。
本章节git分支地址:eventstream-validation
如果你对Agent开发也感兴趣,欢迎点赞收藏+关注。专栏:从零开发一个Coding Agent - 东方小月的专栏 - 掘金