
从零构建 Agent Harness:工具校验、工作区隔离与执行预算
从零构建 Agent Harness:工具校验、工作区隔离与执行预算
模型只负责提出动作,真正执行的是 harness。这篇教程用 TypeScript 从最简循环出发,逐步加上工具参数校验、工作区隔离、执行预算、上下文裁剪、可观测事件与确定性失败测试,全程不依赖真实模型也能跑通。
大多数 AI agent 教程的起点是:接一个语言模型,塞给它几个工具,然后循环到它给出答案为止。这足以演示工具调用,但把真正有意思的工程问题都跳过了——工具失败时怎么办?谁校验参数?什么机制阻止 agent 无限跑下去、执行危险命令或把上下文窗口撑爆?又怎么在不依赖模型随机输出的前提下测试这些行为?
这些正是 agent harness 的职责:它夹在语言模型和模型所操作的环境之间,负责把模型生成的「意图」变成受控的「执行」。本文用 TypeScript 从零搭一个与模型供应商无关的小型 harness,以编码 agent 为例,先写最简执行循环,再逐层加上工具校验、工作区隔离、执行预算、上下文管理、可观测性和确定性的失败测试。所有代码都可以在没有 API key、没有网络的情况下跑通。
准备工作
开始之前确认几件事:
- 语言基础:熟悉 TypeScript,并对 AI agent 的基本概念(消息、工具调用、循环)有初步了解。
- 运行时:Node.js 24 与 npm。本文使用 Node 内置的测试运行器(
node:test),因此不需要额外安装测试框架。 - 依赖策略:尽量少装依赖。除了 TypeScript 编译工具链,核心逻辑只依赖标准库。
- 目录结构:建议按下面的方式组织,后续每一节都会往对应文件里加内容。
agent-harness/
├── src/
│ ├── core/
│ │ ├── types.ts # 契约:Message、Tool、Model、AgentResult
│ │ ├── agent.ts # 执行循环
│ │ └── registry.ts # 工具注册与参数校验(第 2 节加入)
│ ├── models/
│ │ └── fake.ts # 脚本化假模型,用于测试
│ └── tools/ # 具体工具实现
├── tests/
│ └── agent.test.ts
├── package.json
└── tsconfig.json
如果你习惯按里程碑推进,可以把每一节做成一个 git tag,用 git switch --detach <tag> 在快照之间切换,这样能对照着看每一步到底改了什么,而不是一上来就面对最终代码。切换后依次运行 npm ci、npm run typecheck、npm test 和 npm run demo 验证环境。
操作步骤
第一步:确定核心契约
整个设计里最重要的一个架构决定是:模型只提出动作,harness 负责执行动作。当模型请求一次工具调用时,它实际上什么都没做,只是生成了一段结构化请求,包含工具名和参数。harness 必须解释这段请求、调用对应实现、再把结果回传给模型。
先在 src/core/types.ts 里把契约写清楚:
export type ToolCall = {
id: string;
name: string;
arguments: unknown;
};
export type Message =
| { role: 'user'; content: string }
| { role: 'assistant'; content: string; toolCalls?: ToolCall[] }
| { role: 'tool'; callId: string; content: string; isError: boolean };
export interface ToolDefinition {
name: string;
description: string;
parameters: Record<string, unknown>; // 给模型看的 JSON Schema
}
export interface Tool extends ToolDefinition {
execute(argumentsValue: unknown, signal: AbortSignal): Promise<unknown>;
}
export interface Model {
generate(input: {
messages: readonly Message[];
tools: readonly ToolDefinition[];
signal: AbortSignal;
}): Promise<{ content: string; toolCalls?: ToolCall[] }>;
}
export type AgentResult = {
status: 'completed' | 'step_limit' | 'aborted';
answer?: string;
messages: Message[];
steps: number;
};
几个细节值得注意。Model 接口里没有任何 OpenAI、Anthropic 或其他供应商的痕迹,供应商适配器只需在自己的 API 和这些类型之间做转换,harness 本身不用改。Tool 继承 ToolDefinition,意味着模型看到的定义和 harness 持有的可执行实现是同一个对象的两面,但只有 harness 能拿到 execute。
还有一点:工具参数的类型是 unknown,不是 any。模型生成的数据属于不可信输入,必须在使用前校验,这是下一节的主题。
第二步:写出基础执行循环
src/core/agent.ts 里的循环遵循一个固定序列:把对话和可用工具定义发给模型;把模型响应追加进对话;如果它请求了工具,执行并追加结果;重复,直到模型给出最终答案或触达步数上限。
import type { AgentResult, Message, Model, Tool } from './types.ts';
export async function runAgent(options: {
model: Model;
tools: readonly Tool[];
prompt: string;
maxSteps?: number;
signal?: AbortSignal;
}): Promise<AgentResult> {
const { model, tools, prompt, maxSteps = 10 } = options;
if (!Number.isSafeInteger(maxSteps) || maxSteps < 1) {
throw new RangeError('maxSteps must be a positive safe integer');
}
const signal = options.signal ?? new AbortController().signal;
const messages: Message[] = [{ role: 'user', content: prompt }];
const registry = new Map(tools.map(tool => [tool.name, tool]));
if (registry.size !== tools.length) throw new Error('Duplicate tool names');
const definitions = tools.map(({ name, description, parameters }) => ({
name, description, parameters,
}));
for (let step = 0; step < maxSteps; step++) {
if (signal.aborted) return { status: 'aborted', messages, steps: step };
let response;
try {
response = await model.generate({ messages: [...messages], tools: definitions, signal });
} catch (error) {
if (signal.aborted) return { status: 'aborted', messages, steps: step };
throw error;
}
if (signal.aborted) return { status: 'aborted', messages, steps: step };
const calls = response.toolCalls ?? [];
messages.push({
role: 'assistant',
content: response.content,
...(calls.length ? { toolCalls: calls } : {}),
});
if (!calls.length) {
return { status: 'completed', answer: response.content, messages, steps: step + 1 };
}
for (const call of calls) {
if (signal.aborted) return { status: 'aborted', messages, steps: step + 1 };
const tool = registry.get(call.name);
if (!tool) {
messages.push({
role: 'tool', callId: call.id,
content: `Unknown tool: ${call.name}`, isError: true,
});
continue;
}
try {
const result = await tool.execute(call.arguments, signal);
if (signal.aborted) return { status: 'aborted', messages, steps: step + 1 };
messages.push({
role: 'tool', callId: call.id,
content: JSON.stringify(result) ?? 'null', isError: false,
});
} catch (error) {
if (signal.aborted) return { status: 'aborted', messages, steps: step + 1 };
messages.push({
role: 'tool', callId: call.id,
content: error instanceof Error ? error.message : String(error),
isError: true,
});
}
}
}
return { status: 'step_limit', messages, steps: maxSteps };
}
这段代码里有三个行为值得单独说明。第一,没有工具调用的响应会结束整个运行。第二,工具失败不会自动终止 agent,harness 会把错误信息与原始调用 ID 关联后记录下来,让模型在下一轮自行反应——比如换一个路径、换一种方式重试。第三,maxSteps 是硬性上限:模型可以不断请求工作,但它无权决定自己能用多少轮。
第三步:用假模型做确定性测试
不要在测试里调用真实 API。那既不确定,又消耗 token 或算力。用一个脚本化的假模型替代:它按顺序返回预先写好的响应,脚本耗尽就抛错,这样多出来的意外轮次不会静默通过。
// src/models/fake.ts
import type { Model } from '../core/types.ts';
export function scriptedModel(
responses: Array<Awaited<ReturnType<Model['generate']>>>
): Model {
let index = 0;
return {
async generate() {
const response = responses[index++];
if (!response) throw new Error('Scripted model exhausted');
return response;
},
};
}
对应的最小测试放在 tests/agent.test.ts,用 npm test 运行:
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { runAgent } from '../src/core/agent.ts';
import { scriptedModel } from '../src/models/fake.ts';
test('executes a tool and returns the final answer', async () => {
const result = await runAgent({
prompt: 'Say hello',
tools: [{
name: 'hello',
description: 'Return a greeting',
parameters: { type: 'object' },
async execute() { return 'Hello'; },
}],
model: scriptedModel([
{ content: '', toolCalls: [{ id: '1', name: 'hello', arguments: {} }] },
{ content: 'Done' },
]),
});
assert.equal(result.status, 'completed');
assert.equal(result.answer, 'Done');
assert.deepEqual(result.messages[2], {
role: 'tool', callId: '1', content: '"Hello"', isError: false,
});
});
同样的模式可以覆盖未知工具、步数上限和取消。到这里,循环能跑了,但它仍然无条件信任模型生成的参数,也没有对已注册工具能做什么施加任何限制。
第四步:在执行前校验工具调用
工具描述只是对模型的一个请求,不是模型会返回合法参数的保证。把最初的 Map 换成 ToolRegistry,由它负责注册和运行时参数校验:
import type { Tool, ToolDefinition } from './types.ts';
export class ToolRegistry {
private readonly tools = new Map<string, Tool>();
constructor(tools: readonly Tool[]) {
for (const tool of tools) {
if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(tool.name)) {
throw new Error(`Invalid tool name: ${tool.name}`);
}
if (this.tools.has(tool.name)) {
throw new Error(`Duplicate tool name: ${tool.name}`);
}
this.tools.set(tool.name, tool);
}
}
definitions(): ToolDefinition[] {
return [...this.tools.values()].map(({ name, description, parameters }) => ({
name, description, parameters,
}));
}
get(name: string): Tool | undefined {
return this.tools.get(name);
}
}
用 const registry = new ToolRegistry(tools) 创建,用 registry.definitions() 取给模型看的定义。当响应请求某个工具时,先校验参数再调用 execute:
const tool = registry.get(call.name);
if (!tool) {
messages.push({
role: 'tool', callId: call.id,
content: `Unknown tool: ${call.name}`, isError: true,
});
continue;
}
const validationErrors = validate(call.arguments, tool.parameters);
if (validationErrors.length) {
messages.push({
role: 'tool', callId: call.id,
content: `Invalid arguments: ${validationErrors.join('; ')}`, isError: true,
});
continue;
}
这是一条关键边界:arguments: unknown 在校验通过之前始终不可信。校验失败时工具根本不会运行,模型收到的是一条与原始调用 ID 关联的错误结果。
validate 函数只需支持一个刻意做小的 JSON Schema 子集:原始类型与容器类型检查、对象 properties、required 字段、additionalProperties: false、数组 items、以及 enum。报错时给出类似 $.flags[0] 的路径,方便定位格式错误的输入。不要试图实现完整 JSON Schema——子集越小,行为越可预测,测试越好写。
不是所有错误都同一性质。 未知工具、参数非法、工具内部抛错,这三类都该回传给模型,但语义不同:前两类说明模型这一轮请求本身有问题,第三类说明环境或实现出了问题。把它们都标成 isError: true 是合理的,但错误文案要能区分,否则模型无法判断该改参数还是该换策略。
第五步:给 agent 工具,但不给它整台机器
有了校验还不够。一个能读写任意路径的文件工具,等于把整台机器交出去。这里要做两件事。
把文件操作限制在工作区内。 所有路径先解析成绝对路径,再检查它是否位于工作区根目录之下。注意要处理 .. 穿越和符号链接:只做字符串前缀比较是不够的,应当用 path.resolve 归一化后再比较,必要时用 fs.realpath 解析真实路径。任何越界访问都返回错误结果,而不是抛异常终止整个运行。
让模型选择命令,而不是发明命令。 不要提供一个接收任意字符串并交给 shell 执行的工具。更安全的做法是提供一个受限的命令工具:只接受白名单内的命令名和结构化参数,由 harness 拼装成参数数组后以不经过 shell 的方式启动子进程。这样模型无法通过分号、管道或命令替换注入额外指令。
这两条限制都应该在工具实现内部完成,而不是依赖提示词约束模型。提示词是建议,代码才是边界。
第六步:给执行加上预算
只有 maxSteps 是不够的。一个模型可以在单轮里请求十个工具调用,也可以让某个工具跑上几分钟。把两类限制分开:
- 轮次上限:模型最多能进行多少次生成,对应
maxSteps。 - 工具调用上限:整个运行期间最多执行多少次工具,跨轮累计。
在循环里维护一个计数器,每次真正执行工具前递增并检查;超限时返回一个明确的终止状态,而不是静默截断。同时把 AbortSignal 一路传下去:传给 model.generate,也传给每个 tool.execute。工具实现应当在耗时操作中检查 signal.aborted,让取消和超时能真正中断工作,而不是等它自然结束。
这些限制同样要能测试,而且不必等真实故障发生。用假模型脚本出「连续请求工具直到超限」的序列,断言返回状态是预算耗尽;用 AbortController 在第一次工具调用后立即 abort(),断言状态是 aborted。整个过程毫秒级完成。
第七步:控制上下文规模
上下文窗口是有限资源,而工具输出往往是最容易失控的部分:一次目录遍历、一次日志读取,就可能返回几十万字符。两个措施:
- 限制工具输出:在把结果写入消息之前截断,并明确标注「已截断」,让模型知道信息不完整。截断阈值按字符数或字节数设定,写进配置而不是散落在各处。
- 限制面向模型的历史:当消息总量超过阈值时,从最早的轮次开始裁剪,但保留系统提示与最近若干轮。
裁剪时有一条硬规则:不要把一次工具调用和它的结果拆开。如果 assistant 消息里带着 toolCalls,那么对应的 tool 结果消息必须一起保留或一起移除。否则模型会看到一次没有结果的调用,或者一条没有来源的结果,两种都会让后续推理变得混乱。
第八步:让执行过程可观测
在执行的边界上发出事件,而不是在实现内部随意打日志。典型的事件点包括:运行开始、每轮模型调用前后、每次工具调用前后、运行结束。事件里带上轮次序号、工具名、耗时和结果状态,这样出问题时能还原出完整时间线。
关于用量统计要克制:只记录你真正拿得到的数据。如果供应商适配器返回了 token 用量,就透传;如果没有,就不要估算一个看起来合理的数字填进去。编造的用量指标比没有指标更糟,因为它会让成本分析建立在错误前提上。
第九步:测试失败路径
正常路径的测试只能证明一切顺利时能跑。真正需要覆盖的是失败:
- 在信任模型响应之前先校验它:假模型可以返回结构不合法的响应,比如缺少
content、toolCalls不是数组、调用缺少id。harness 应当拒绝这类响应并给出明确错误,而不是让undefined一路渗透到下游。 - 被拒绝的批次不要进入对话记录:如果一轮响应整体不合法,不要把它追加进
messages。否则后续轮次会带着一条残缺的 assistant 消息继续,模型看到的历史本身就是坏的。 - 用可复现的变体覆盖分支:同一个测试文件里,用不同的脚本序列分别触发未知工具、参数非法、工具抛错、步数超限、预算超限、中途取消。每个用例都断言
status、steps和关键消息内容。
一个完整示例
下面把前面的部件串成一个能跑通的最小例子:一个受限的 read_file 工具、一个脚本化模型、一次成功调用,以及一次参数校验失败。
import { test } from 'node:test';
import assert from 'node:assert/strict';
import path from 'node:path';
import { readFile } from 'node:fs/promises';
import { runAgent } from '../src/core/agent.ts';
import { ToolRegistry } from '../src/core/registry.ts';
import { scriptedModel } from '../src/models/fake.ts';
const workspace = path.resolve('/tmp/agent-workspace');
function insideWorkspace(target: string): string {
const resolved = path.resolve(workspace, target);
if (resolved !== workspace && !resolved.startsWith(workspace + path.sep)) {
throw new Error(`Path escapes workspace: ${target}`);
}
return resolved;
}
const readFileTool = {
name: 'read_file',
description: 'Read a UTF-8 text file inside the workspace',
parameters: {
type: 'object',
properties: { path: { type: 'string' } },
required: ['path'],
additionalProperties: false,
},
async execute(args: unknown) {
const { path: target } = args as { path: string };
return await readFile(insideWorkspace(target), 'utf8');
},
};
test('reads a file, then reports a validation error', async () => {
const registry = new ToolRegistry([readFileTool]);
const model = scriptedModel([
{ content: '', toolCalls: [{ id: 'c1', name: 'read_file', arguments: { path: 'notes.txt' } }] },
{ content: '', toolCalls: [{ id: 'c2', name: 'read_file', arguments: { path: 42 } }] },
{ content: 'Finished' },
]);
const result = await runAgent({
model,
tools: [readFileTool],
prompt: 'Read notes.txt and summarize it',
maxSteps: 5,
});
assert.equal(result.status, 'completed');
assert.equal(result.answer, 'Finished');
const first = result.messages.find(m => m.role === 'tool' && m.callId === 'c1');
assert.equal(first?.isError, false);
const second = result.messages.find(m => m.role === 'tool' && m.callId === 'c2');
assert.equal(second?.isError, true);
assert.match(second!.content, /Invalid arguments/);
});
这个例子里,第一次调用正常返回文件内容,第二次因为 path 不是字符串被校验拦下,工具根本没有执行,模型收到一条带 $.path 路径的错误信息,然后在第三轮给出最终答案。整个测试不访问网络、不消耗 token,结果完全确定。
注意事项
- 参数校验是边界,不是装饰:
unknown在校验通过前一律不可信。校验失败时不要「尽力而为」地调用工具,直接返回错误结果。 - 工具失败不终止运行:把错误与调用 ID 关联后回传,让模型有机会调整策略。但预算耗尽和取消应当终止运行,并返回明确状态。
- 路径检查要归一化:只做字符串前缀比较挡不住
..穿越。先path.resolve,必要时再解析真实路径。 - 不要提供任意 shell 执行工具:白名单命令加结构化参数,以不经过 shell 的方式启动子进程。
- 裁剪历史时保持调用与结果成对:拆开会让模型看到自相矛盾的历史。
- 不要编造用量数据:拿不到就不记录,估算值会污染成本分析。
- 假模型脚本耗尽要抛错:否则多出来的意外轮次会静默通过测试。
- 版本与依赖信息以官网当前信息为准:Node.js 版本、TypeScript 版本和各类配额都可能变化,落地前请核对官方文档。
把真实语言模型接进来是最后一步,也是最简单的一步:写一个实现 Model 接口的适配器,把供应商的请求与响应映射到 Message、ToolDefinition 和 ToolCall 上。harness 本身不需要任何改动——这正是把契约和实现分开的价值所在。