How to Build an AI Agent Harness from Scratch in TypeScript
TL;DR · AI 摘要
构建一个独立于提供者的TypeScript AI代理框架,涵盖工具验证、上下文管理和失败测试,提升代理系统的可靠性和可测试性。
核心要点
- 使用TypeScript和Node.js 24实现工具验证与工作区隔离
- 通过执行预算和上下文绑定防止资源耗尽
- 采用确定性失败测试确保系统可靠性
结构提纲
按章节快速跳转。
- §引言
指出现有AI代理教程未解决工程实践中的关键问题。
解释agent harness在模型与环境间的桥梁作用。
演示如何用TypeScript创建脱离真实模型的测试循环。
通过注册表实现显式契约验证,区分错误类型。
设置独立的执行次数和工具调用限制并行传播取消信号。
采用可重复变异验证模型响应,隔离被拒绝批次。
思维导图
用一张图看清主题之间的关系。
查看大纲文本(无障碍 / 无 JS 友好)
- AI代理框架构建
- 核心模块
- 工具验证
- 上下文管理
- 执行限制
- 关键技术
- TypeScript
- Node.js 24
- 测试策略
- 确定性失败测试
- 可观测性设计
金句 / Highlights
值得收藏与分享的关键句。
工具验证应区分错误类型,而非简单处理所有错误
工作区隔离通过文件操作限制实现,防止模型发明不存在的命令
确定性失败测试可提前验证模型响应,无需等待真实故障
如何从零开始用 TypeScript 构建 AI 代理框架
2026年10月20日
/
#ai agents
许多 AI 代理教程都是从语言模型开始,赋予它几个工具,然后运行循环直到生成答案。这足以演示工具调用,但忽略了更有趣的工程问题。
当工具失败时会发生什么?谁来验证参数?什么机制能防止代理无限运行、执行危险命令或耗尽上下文窗口?又如何在不依赖不可预测模型响应的情况下测试这些内容?
这些问题属于代理框架的职责范畴:这是位于语言模型与运行环境之间的软件层。
在本教程中,我们将用 TypeScript 构建一个小型、与提供方无关的框架,以编码代理作为示例。我们将从基本执行循环开始,逐步添加工具验证、工作区隔离、执行预算、上下文管理、可观测性以及确定性失败测试。
我们将涵盖的内容
- 先决条件
- 1. 在没有真实模型的情况下构建基本代理循环测试
- 2. 执行前验证工具调用 注册表与显式契约 每个错误的意义可能不同
- 3. 为代理提供工具但不赋予机器权限 限制文件操作到工作区 让模型选择命令而非发明命令
- 4. 对执行次数和工具调用分别设限 传播取消和截止时间 在不等待真实失败的情况下测试限制
- 5. 控制上下文范围 限制工具输出和面向模型的历史记录 不要将工具调用与其结果分离
- 6. 使代理执行可观测 在执行边界发出事件 在不虚构的情况下统计模型使用情况
- 7. 测试失败路径而非仅成功路径 在信任模型响应前验证它 将被拒绝的批次排除在转录之外 使用可复现的变体
- 下一步:连接真实语言模型
先决条件
熟悉 TypeScript 和 AI 代理的基础知识将有所帮助。我们将使用 Node.js 24、TypeScript 及其内置的测试运行器,依赖项极少。
完整实现可在配套仓库中找到:agent-harness-from-scratch。每个章节对应一个 Git 标签,因此你可以跟随项目开发过程,而非直接从最终代码开始。
1. 构建基本代理循环
要跟随教程,请检出初始里程碑:
git clone https://github.com/gkoos/agent-harness-from-scratch.git
cd agent-harness-from-scratch
git switch --detach 01-model-loop
npm ci
npm run typecheck
npm test
npm run demo分离检出功能让你精确停留在此处讨论的快照版本,而无需移动分支。当你进入下一章节时,切换到对应标签。你需要安装 Node.js 24 和 npm。
最重要的架构决策是:模型提出操作而框架执行它们。当模型请求工具调用时,实际上尚未执行任何操作。它只是生成了一个包含工具名称和参数的结构化请求。框架必须解析该请求,调用对应实现,并将结果返回给模型。
交互流程如下:
我们首先从 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 for the model
}
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;
};模型接口刻意不包含任何特定于 OpenAI、Anthropic 或其他提供商的专有内容。提供商适配器可以在不修改框架的情况下,将其 API 与这些类型进行转换。
请注意工具参数使用的是 unknown 而不是 any。模型生成的数据属于不可信输入。我们将在下一节执行前对其进行验证。
src/core/agent.ts 中的代理循环遵循简单序列:
- 将对话和可用工具定义发送给模型
- 将模型的响应追加到对话中
- 如果请求了工具,执行工具并追加结果
- 重复直到模型返回最终答案或框架达到步骤限制
以下是 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 };
}不包含工具调用的响应会结束运行。否则,框架会查找每个请求的工具并执行它。
工具失败不会自动终止代理。相反,框架会记录与原始调用ID相关联的错误信息。这使模型能够在下一次回合中对缺失文件或失败操作做出反应。
我们还对模型回合数设定了最大限制。模型可以持续请求工作,但它无法决定自己应得的执行时间或迭代次数。
不使用真实模型的测试
在测试中,我们不进行不确定性的API调用(以免消耗令牌或GPU资源),而是在src/models/fake.ts中引入了一个脚本模拟的虚假模型。它返回预定义的响应:首先是工具请求,然后是最终答案。
这个虚假模型会按响应数组顺序推进。用尽脚本内容会被视为错误,因此意外的额外模型回合不会静默通过:
// 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
});
});配套演示使用了相同的模式。其他测试用例覆盖了未知工具、步骤限制以及在不使用API密钥、网络访问或令牌成本的情况下进行取消操作的情况。
到目前为止,我们已经实现了可用的代理循环,但它仍然信任模型生成的工具参数,并且对已注册工具能执行的操作没有实质性的限制。
2. 在执行工具调用前进行验证
我们的第一个循环允许模型通过名称请求工具,然后直接将其参数传递给实现。这在演示中有效,但工具描述只是对模型的请求,并不能保证模型会返回有效参数。
在本节中,请查看配套仓库的第二个里程碑:
git switch --detach 02-tool-dispatch此标签添加了 src/core/registry.ts ,该文件处理工具注册和运行时参数验证。它还修改了 src/core/agent.ts 中的失败行为。
带显式契约的注册表
ToolRegistry 替换了初始循环中的普通 Map 。它拒绝无效或重复的工具名称,仅暴露模型需要的定义:
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;
}这是一个关键的边界。在 harness 检查之前,arguments: unknown 始终是不可信的。如果验证失败,工具将永远不会运行。模型会收到与原始调用 ID 关联的错误结果。
教程中的 validate 函数故意支持一个较小的 JSON Schema 子集:基本类型和容器类型检查、对象属性、必填字段、additionalProperties: false、数组项和枚举。它报告类似 $.flags[0] 的路径以帮助识别格式错误的输入。这不是完整的 JSON Schema 实现,也不会在注册时验证工具模式。
以下是上述验证器的实现。它首先检查声明的类型,然后递归检查对象属性和数组项:
export function validate(value: unknown, schema: Record<string, unknown>, path = '$'): string[] {
const errors: string[] = [];
const type = schema.type;
if (typeof type !== 'string') return [`${path}: schema must declare a type`];
const matches = type === 'object' ? value !== null && typeof value === 'object' && !Array.isArray(value)
: type === 'array' ? Array.isArray(value)
: type === 'integer' ? typeof value === 'number' && Number.isInteger(value)
: type === 'number' ? typeof value === 'number' && Number.isFinite(value)
: type === 'null' ? value === null
: type === 'string' || type === 'boolean' ? typeof value === type : false;
if (!matches) return [`${path}: expected ${type}`];
if (Array.isArray(schema.enum) && !schema.enum.some(item => Object.is(item, value))) {
errors.push(`${path}: not an allowed value`);
}
if (type === 'object') {
const obj = value as Record<string, unknown>;
const properties = (schema.properties ?? {}) as Record<string, Record<string, unknown>>;
for (const key of (schema.required ?? []) as string[]) {
if (!Object.hasOwn(obj, key)) errors.push(`${path}.${key}: required`);
}
for (const [key, item] of Object.entries(obj)) {
if (Object.hasOwn(properties, key)) errors.push(...validate(item, properties[key]!, `${path}.${key}`));
else if (schema.additionalProperties === false) errors.push(`${path}.${key}: unexpected property`);
}
}
if (type === 'array' && schema.items && typeof schema.items === 'object') {
(value as unknown[]).forEach((item, index) => {
errors.push(...validate(
item, schema.items as Record<string, unknown>, `${path}[${index}]`
));
});
}
return errors;
}并非所有错误都意味着相同的问题
第二阶段里程碑还明确区分了工具故障和模型故障。
未知工具、无效参数或工具抛出的异常会生成一个类型为 'tool' 且 isError: true 的消息。模型可以检查该结果,并在下一轮尝试其他操作。相比之下,如果 model.generate() 本身抛出异常,系统会以状态 'model_error' 终止,并记录错误信息。
这不仅仅是记录信息的问题。文件缺失可能是可恢复的,但模型提供方不可用意味着没有模型响应来指导恢复。系统不应假装这两种情况是相同的。
在现有 model.generate() 的 catch 块中,将重新抛出异常替换为结构化结果,并在 AgentResult 中添加 'model_error' 和可选的 error?: string 字段:
if (signal.aborted) return { status: 'aborted', messages, steps: step };
return {
status: 'model_error', messages, steps: step,
error: error instanceof Error ? error.message : String(error)
};工具执行的 catch 块仍然记录错误信息并继续循环。
确定性测试使这一区分具体化。在 tests/agent.test.ts 中,无效的 echo 参数在不调用工具的情况下被拒绝,而工具抛出的异常仍允许脚本化模型返回最终答案。tests/registry.test.ts 检查嵌套参数验证和意外属性。
3. 为代理提供工具但不提供机器权限
到目前为止,我们的代理可以请求工具,而系统会验证其参数。但验证仅确认请求具有正确的结构。对 ../../secrets.txt 的有效读取请求仍然是我们应该拒绝的请求。
查看配套仓库中的第三个里程碑:
git switch --detach 03-workspace-tools此里程碑新增了工作区文件工具和受限命令工具。这两个工具都不依赖特定模型提供商。
限制文件操作范围到工作区
编码代理需要读取和编辑文件,但不应自动获得整个文件系统的访问权限。Workspace 类在创建时会解析规范化的根路径:
const workspace = await Workspace.open('/path/to/project');
const tools = workspaceTools(workspace);生成的 read_file 和 write_file 工具接受相对于该根路径的路径。在打开文件前,Workspace 会拒绝绝对路径、逃逸根路径的路径以及现有路径组件中的符号链接。写入操作需要父目录已存在。该工具不会创建任意的目录树。
Workspace 类内的这两个方法强制执行路径检查。文件导入了 node:path 的 isAbsolute、relative 和 resolve,以及 node:fs/promises 的 lstat 和 realpath。Workspace.open() 会将 await realpath(root) 存储为 this.root:
private lexical(path: string): string {
if (!path || isAbsolute(path)) throw new Error('Expected a relative workspace path');
const full = resolve(this.root, path);
const rel = relative(this.root, full);
if (!rel || rel === '..' || rel.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`) || isAbsolute(rel)) {
throw new Error('Path is outside the workspace or refers to its root');
}
return full;
}
/** 检查每个现有组件中的符号链接,而不仅仅是最终文件。 */
private async check(path: string, allowMissingFinal: boolean): Promise<string> {
const full = this.lexical(path);
const rel = relative(this.root, full);
const parts = rel.split(/[\\/]/);
let current = this.root;
for (let i = 0; i < parts.length; i++) {
current = resolve(current, parts[i]!);
try {
const stat = await lstat(current);
if (stat.isSymbolicLink()) throw new Error('Symbolic links are not permitted');
if (i < parts.length - 1 && !stat.isDirectory()) throw new Error('Parent is not a directory');
} catch (error) {
if (allowMissingFinal && i === parts.length - 1 && (error as NodeJS.ErrnoException).code === 'ENOENT') break;
throw error;
}
}
return full;
}读取操作在打开现有文件前会调用 this.check(path, false)。写入操作会调用 this.check(path, true),允许最终文件缺失。缺失的父目录或任何符号链接仍会失败检查。
边界限制由工具实现强制执行,而不是要求模型行为符合规范。模型可以请求 ../secret.txt,但工作区会拒绝打开该文件。此时框架会将失败报告为可恢复的工具结果,与第二个里程碑中处理无效参数的方式一致。
额外的检查中,实现会在支持的系统上使用 O_NOFOLLOW 打开文件,避免在最终路径组件处跟随符号链接。这并不能消除所有竞态条件:在检查和打开目录组件之间,目录内容可能发生变更。这是一个有用的教育性限制,而非操作系统级别的沙箱。
const tool = commandTool(projectRoot, {
test: {
executable: process.execPath,
args: ['--test', 'tests/example.test.js']
}
});该模型可以请求 { "command": "test" },但无法指定不同的可执行文件、额外参数或shell语法。命令工具的调度边界如下(在其 execute(value, signal) 方法内部):
signal.throwIfAborted();
const name = (value as { command: string }).command;
if (!Object.hasOwn(presets, name)) throw new Error('Command not permitted');
const preset = presets[name]!;
const timeout = preset.timeoutMs ?? 10_000;
if (!Number.isSafeInteger(timeout) || timeout < 1) {
throw new Error('Invalid timeout');
}
// 在收集命令结果的 Promise 内部:
const child = spawn(preset.executable, [...preset.args], {
cwd, shell: false, signal, windowsHide: true,
stdio: ['ignore', 'pipe', 'pipe']
});
const timer = setTimeout(() => child.kill(), timeout);此处 spawn 来自 node:child_process,cwd 是配置的项目根目录,presets 是开发者提供的上方映射。结果处理器会收集 stdout 和 stderr,如果任一捕获的字符串超过 65,536 字符则终止子进程,并在发生错误或关闭时清除定时器。实现中测量的是 JavaScript 字符串长度,而非精确字节数。
这减少了模型可选择的命令数量,但并未使所选命令变得无害。测试脚本本身可以读取文件、访问网络或启动子进程。超时终止子进程也无法可靠地终止其整个进程树。运行不可信仓库仍需要进程隔离,例如正确配置的容器或操作系统沙箱。
里程碑 3 的测试涵盖正常读写、遍历尝试、符号链接、可恢复的访问失败以及被拒绝的命令名称。
4. 对执行设置限制
现在我们的代理可以调用经过验证的工具,包括感知工作区的文件操作和预定义命令。但一个有效的操作不一定是我们应该无限制允许的。模型可能会请求数百次工具调用,或者命令可能在代理等待结果时挂起。
在此阶段,我们将执行限制作为测试框架的责任,而不是要求模型去遵守。
使用精确的仓库版本进行跟踪:
git switch --detach 04-execution-limits如果你尚未克隆项目,请从 git clone https://github.com/gkoos/agent-harness-from-scratch.git 开始,并执行 cd agent-harness-from-scratch 。
分别统计回合数和工具调用次数
原始的 maxSteps 设置限制的是模型响应次数,而非工具调用数量。一次响应可以请求多个工具,因此仅设置回合限制无法限制工作量。
我们为 runAgent 添加两个额外选项:
const result = await runAgent({
model,
tools,
prompt: 'Check the project',
maxSteps: 10,
maxToolCalls: 30,
timeoutMs: 15_000
});每次请求的调用都会消耗工具调用配额,包括未知工具和参数无效的调用。这很重要,因为即使失败的请求也会消耗测试框架的工作量。在循环开始前初始化 let toolCalls = 0,然后在查找每个允许调用的工具前立即递增该变量。这种设置方式也会统计被拒绝的参数和未知工具。
模型还可能一次返回多个工具调用。为了避免先执行前几个再发现预算不足的情况,框架会先检查整个批次是否在预算范围内:
if (calls.length > maxToolCalls - toolCalls) {
return { status: 'tool_limit', messages, steps, toolCalls };
}这防止了因预算不足导致的局部执行。但不会让批次操作具有事务性:工具仍可能失败,或在执行过程中被取消。
传播取消和截止时间
调用方可以传入 AbortSignal,而 timeoutMs 会创建一个内部截止时间。框架通过 AbortController 将这些信号合并,并将生成的信号传递给模型和工具。
此设置替换了初始循环中直接选择的信号:
const controller = new AbortController();
let timedOut = false;
const onAbort = () => controller.abort(options.signal?.reason);
if (options.signal?.aborted) onAbort();
else options.signal?.addEventListener('abort', onAbort, { once: true });
const timer = timeoutMs === undefined ? undefined : setTimeout(() => {
timedOut = true;
controller.abort(new Error('Agent deadline exceeded'));
}, timeoutMs);
const signal = controller.signal;将此信号传递给 model.generate() 和 tool.execute(),方式与原始循环一致。用 try/finally 包裹循环,确保完成和提前返回时都能释放定时器和监听器:
try {
// 执行循环在此处,使用 controller.signal
} finally {
if (timer !== undefined) clearTimeout(timer);
options.signal?.removeEventListener('abort', onAbort);
}在执行边界处,检查 signal.aborted 并返回 timedOut ? 'timeout' : 'aborted' 作为状态。结果还会报告 steps 和 toolCalls,这样调用方可以看到终止前完成了多少工作。
这是协作式取消,而非强制终止。如果适配器忽略其信号,未完成的操作可能超出截止时间。同样,杀死子进程不一定能停止其子进程。硬性执行边界需要进程或容器隔离。
在不等待真实失败的情况下测试限制
预算测试使用脚本模型请求超出预算的批次,并验证是否没有工具执行。其他测试检查无效请求是否消耗预算、预取消的信号是否阻止模型调用,以及协作式模型和工具是否能在截止时间停止。
types.ts 中的结果类型现在区分了 completed、step_limit、tool_limit、aborted、timeout 和 model_error。这些结果故意设计为不同:运行可能在未完成用户任务的情况下正确停止。
5. 控制上下文长度
框架现在对运行时长有限制,但对话内容仍在持续增长。每个模型响应和工具结果都会成为发送回模型的另一条消息。单个文件读取可能返回数千行内容。多次迭代可能在达到步骤预算前就耗尽模型的上下文窗口。
第五个里程碑引入了我们保留的转录内容与发送给模型的消息之间的区分。查看实现:
git switch --detach 05-context相关文件是 src/core/context.ts、src/core/agent.ts 和 tests/context.test.ts。
限制工具输出和面向模型的历史记录
新的上下文选项设置了两个独立的限制:
const result = await runAgent({
prompt: 'Inspect the project',
model,
tools,
context: {
maxContextChars: 32_000,
maxToolResultChars: 8_000
}
});maxToolResultChars 会在工具结果进入对话记录前进行限制。超出限制的成果会保留开头和结尾,并在中间添加省略标记。被截断的部分后续无法获取,即使在 result.messages 中也是如此。
截断辅助函数会为标记预留空间,然后将剩余空间分配给开头和结尾:
export function boundToolResult(content: string, limit: number): string {
if (content.length <= limit) return content;
const marker = `\n...[${content.length - limit} or more characters omitted]...\n`;
if (marker.length >= limit) return marker.slice(0, limit);
const remaining = limit - marker.length;
const front = Math.ceil(remaining / 2);
const back = Math.floor(remaining / 2);
return content.slice(0, front) + marker + (back ? content.slice(-back) : '');
}在记录成功结果和错误信息时都要应用这个函数:content: boundToolResult(content, context.maxToolResultChars) 。这个简洁版本明确处理了零长度尾部,避免 slice(-0) 误保留完整结果。
maxContextChars 限制传递给模型的序列化消息数组大小。框架会在返回的对话记录中保留较早的消息,但 selectContext() 会发送初始用户请求加上尽可能多的最新对话轮次。没有摘要功能:被省略的历史记录在该轮次中对模型完全不可用。
这些是字符限制而非令牌限制。它们测量的是 JSON 序列化后的 JavaScript 字符串长度,不包括工具定义和特定提供方的开销。实际的提供方适配器需要实现基于令牌的计数,并为响应预留空间。
不要将工具调用与其结果分开
上下文裁剪不仅仅是删除最旧的消息。助手响应可能请求多个工具,每个工具都有唯一的调用 ID。只发送部分结果会导致对话无效或产生误导。
因此选择器会将助手消息和该批次的所有结果视为不可分割的组。要么完整包含该组,要么完全排除。它还会拒绝孤立的结果和不完整的批次,而不是静默构建不一致的上下文。
以下是 src/core/context.ts 中的 selectContext() ,使用第1节定义的 Message 类型。第一个循环验证并分组对话记录。第二个循环从完整组开始向后遍历,直到预算用完:
import type { Message } from './types.ts';
const size = (messages: readonly Message[]) => JSON.stringify(messages).length;
export function selectContext(messages: readonly Message[], maxChars: number): Message[] {
if (!Number.isSafeInteger(maxChars) || maxChars < 1) {
throw new RangeError('maxContextChars must be a positive safe integer');
}
if (!messages.length) return [];
const groups: Message[][] = [];
for (let i = 0; i < messages.length;) {
const message = messages[i]!;
if (message.role === 'assistant' && message.toolCalls?.length) {
const ids = new Set(message.toolCalls.map(call => call.id));
if (ids.size !== message.toolCalls.length) throw new Error('Duplicate tool call IDs in assistant turn');
const group: Message[] = [message];
i++;
while (i < messages.length && messages[i]!.role === 'tool') {
const result = messages[i]!;
if (result.role === 'tool' && !ids.delete(result.callId)) throw new Error('Orphan or duplicate tool result');
group.push(result);
i++;
}
if (ids.size) throw new Error('Incomplete tool result batch');
groups.push(group);
} else {
if (message.role === 'tool') throw new Error('Orphan tool result');
groups.push([message]);
i++;
}
}
const first = groups[0]!;
if (first[0]?.role !== 'user') throw new Error('Conversation must start with a user message');
const recent: Message[][] = [];
for (let i = groups.length - 1; i >= 1; i--) {
const group = groups[i]!;
const next = [...first, ...[group, ...recent].flat()];
if (size(next) > maxChars) {
if (recent.length === 0) throw new Error('Context budget too small for latest turn');
break;
}
recent.unshift(group);
}
const selected = [...first, ...recent.flat()];
if (size(selected) > maxChars) throw new Error('Context budget too small for required messages');
return selected;
}在每次模型调用之前,使用 selectContext(messages, context.maxContextChars) 作为其消息输入。如果初始提示或最近的必要消息组无法适应上下文预算,选择器会抛出异常,Harness 会返回 context_limit。这是一个执行决策,而非模型可恢复的工具错误。
确定性测试验证了批次保留、孤儿检测、输出截断和面向模型的投影。这些测试不仅验证上下文是否更小,还验证它是否仍然代表一个连贯的对话。接下来,我们将使这些决策可观察。
6. 使代理执行可观察
代理在执行过程中可能会做一些意外的事情,例如重试失败的操作、进行不必要的模型调用,或大部分时间停留在某个工具中,但仍可能返回正确的答案。最终答案本身无法告诉我们发生了什么。我们需要执行事件,而无需将 Harness 转变为日志框架。
在配套仓库中查看这个里程碑:
git switch --detach 06-observability实现位于 src/core/events.ts,并在 src/core/agent.ts 中进行了集成。
在执行边界发出事件
我们向 runAgent 添加一个可选的 onEvent 回调。Harness 在模型调用周围发出 model_start 和 model_end 事件,在工具调度周围发出 tool_start 和 tool_end 事件,在模型操作失败时发出 model_error 事件,在运行终止时发出 run_end 事件。
每个事件都有运行 ID、递增的序列号和时间戳。模型和工具完成事件还会报告持续时间,使用单调时钟进行测量。这为我们提供了足够的信息来重建操作顺序并识别缓慢的调用。
const events: AgentEvent[] = [];
const result = await runAgent({
prompt: 'Inspect the project',
model,
tools,
onEvent: event => events.push(event),
});
console.log(result.runId, events);从 src/core/events.ts 导入 AgentEvent。回调是可选的:不需要遥测功能的调用者可以省略此参数。
一个微妙的要求是可观测性不应控制执行。事件监听器中的异常不应导致成功的工具调用失败。事件发射器会捕获监听器异常,并通过 onObserverError 可选地报告这些异常,而该处理程序中的失败也会被抑制。不过,回调是同步的,因此缓慢的监听器仍会延迟代理的执行。
发射器使用 node:crypto 的 randomUUID() 方法生成一次 runId,并将序列初始化为零。其 emit(event) 方法会丰富事件并隔离回调:
const enriched = {
...event, runId, sequence: ++sequence,
timestamp: new Date().toISOString()
};
try {
options.onEvent?.(enriched);
} catch (error) {
try { options.onObserverError?.(error); }
catch { /* 观察器失败不会改变运行结果。 */ }
}在模型或工具边界处,在执行前保存 const start = performance.now(),并在之后发出 durationMs: performance.now() - start。共享的 finish() 辅助函数在返回每个终端结果前发出 run_end 事件,因此早期退出也保持可观测性。
在不虚构的情况下考虑模型使用情况
我们还可以通过可选的提供方报告的令牌使用情况来扩展模型响应:
return {
content: 'Done',
usage: { inputTokens: 125, outputTokens: 20 },
};框架会在 result.usage 中累积报告的计数,并与 modelCallsWithUsage 一起记录。最后一个字段很重要:零报告令牌且零使用情况报告调用意味着使用情况未知,而不是免费运行。无效的计数会被拒绝,而不是静默地破坏总计数。
累积器使用可选的输入和输出计数,并验证单个值和总计数:
export type Usage = { inputTokens?: number; outputTokens?: number };
export type UsageTotals = {
inputTokens: number; outputTokens: number; modelCallsWithUsage: number
};
export function addUsage(totals: UsageTotals, usage: Usage | undefined): void {
if (!usage) return;
for (const value of [usage.inputTokens, usage.outputTokens]) {
if (value !== undefined && (!Number.isSafeInteger(value) || value < 0)) {
throw new RangeError('Model usage must contain nonnegative safe integers');
}
}
if (!Number.isSafeInteger(totals.inputTokens + (usage.inputTokens ?? 0)) ||
!Number.isSafeInteger(totals.outputTokens + (usage.outputTokens ?? 0))) {
throw new RangeError('Accumulated model usage exceeds safe integer range');
}
totals.inputTokens += usage.inputTokens ?? 0;
totals.outputTokens += usage.outputTokens ?? 0;
totals.modelCallsWithUsage++;
}在运行开始时将总计数初始化为零。将 usage?: Usage 添加到模型响应契约中,在验证每个响应后调用 addUsage(totals, response.usage),并在结果中包含总计数的副本。缺少的使用情况不会改变计数器。
事件负载故意省略了提示、工具参数和工具结果。错误字符串可能仍包含敏感数据,因此导出它们需要谨慎。这是轻量级的内存内检测,而不是分布式追踪或持久化审计日志。
tests/events.test.ts 中的测试验证事件顺序、关联性、使用统计、观察者隔离和终端事件。
7. 测试失败路径,而不仅仅是成功路径
我们的框架现在具备了工具验证、执行限制、上下文管理和可观测性功能。但这些机制之间存在交互。模型可能返回格式错误的工具调用,取消操作可能在批量处理中途到达,或者被拒绝的批量可能使对话陷入无法返回模型的状态。最终里程碑直接测试这些边界条件。
在配套仓库中查看最终里程碑:
git switch --detach 07-testing新测试位于 tests/failures.test.ts。它们使用与我们第一个里程碑相同的脚本模型,因此无需 API 密钥即可复现失败情况。
在信任模型响应之前进行验证
仅验证工具参数是不够的。模型适配器也可能返回格式错误的响应:缺少内容、非数组的 toolCalls、缺失参数或重复的调用 ID。新的 validateModelResponse() 函数在框架记录响应或执行任何操作之前,会先检查响应封装。
import type { Model, ToolCall } from './types.ts';
/** 在接受模型回合或运行副作用之前验证适配器边界。 */
export function validateModelResponse(value: unknown): asserts value is Awaited<ReturnType<Model['generate']>> {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
throw new TypeError('模型响应必须是对象');
}
const response = value as Record<string, unknown>;
if (typeof response.content !== 'string') throw new TypeError('模型响应内容必须是字符串');
if (response.toolCalls === undefined) return;
if (!Array.isArray(response.toolCalls)) throw new TypeError('模型 toolCalls 必须是数组');
const ids = new Set<string>();
for (const call of response.toolCalls as unknown[]) {
if (call === null || typeof call !== 'object' || Array.isArray(call)) {
throw new TypeError('工具调用必须是对象');
}
const item = call as Partial<ToolCall>;
if (typeof item.id !== 'string' || !item.id.trim()) throw new TypeError('工具调用 id 必须是非空字符串');
if (typeof item.name !== 'string' || !item.name.trim()) {
throw new TypeError('工具调用名称必须是非空字符串');
}
if (!Object.hasOwn(item, 'arguments')) throw new TypeError('工具调用参数缺失');
if (ids.has(item.id)) throw new TypeError(`重复的工具调用 id: ${item.id}`);
ids.add(item.id);
}
}在 model.generate() 之后立即调用 validateModelResponse(response) 并在 try 块中添加 addUsage(usage, response.usage),在追加助手消息或运行工具之前。如果任一操作抛出异常,发出 model_error 事件并返回 finish('model_error', { error: message }),其中 message 是异常信息。
格式错误的响应会以 model_error 终止,不会产生任何工具副作用。这与有效工具调用但参数无效的情况不同:后者会生成可恢复的工具错误,允许模型重试。
将被拒绝的批量排除在对话记录之外
测试还发现了一个真实集成错误。之前,框架在检查批量是否超过 maxToolCalls 之前,就已将包含工具调用的助手消息追加到对话记录中。拒绝该批量会导致对话记录中出现未回答的工具调用。
修复方案虽小但很重要:在记录助手回复前检查预算。
if (calls.length > maxToolCalls - toolCalls) {
return finish('tool_limit');
}
messages.push({
role: 'assistant',
content: response.content,
...(calls.length ? { toolCalls: calls } : {})
});这使预算拒绝操作与对话记录保持原子性。即使在已确认的批次中发生中断,仍可能导致终端对话记录不完整。要恢复此类运行需要进行数据对账。
使用可复现的随机变化
除了针对格式错误响应、中断和观察者故障的定向测试外,我们还将运行150个使用不同批次大小和预算的预设工具批次场景。固定伪随机种子可在每次运行时生成相同序列,使故障可复现。
以下示例属于 tests/failures.test.ts 文件。它们共享一个无操作工具和一个生成规范调用的辅助函数:
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';
import type { Tool } from '../src/core/types.ts';
const noop: Tool = {
name: 'noop', description: 'Return a fixed result',
parameters: { type: 'object' },
async execute() { return 'ok'; }
};
const call = (id: string) => ({ id, name: 'noop', arguments: {} });第一个测试将预算从0到8、批次大小从0到12进行变化。它检查执行次数、终止状态以及被拒绝调用是否被排除在对话记录之外:
test('seeded batches respect tool budgets', async () => {
let seed = 0x12345678;
const next = () => {
seed ^= seed << 13;
seed ^= seed >>> 17;
seed ^= seed << 5;
return seed >>> 0;
};
for (let i = 0; i < 150; i++) {
const limit = next() % 9;
const batch = next() % 13;
let executed = 0;
const result = await runAgent({
prompt: 'test', maxToolCalls: limit,
tools: [{
...noop,
async execute() { executed++; return 'ok'; }
}],
model: scriptedModel([
{
content: '',
toolCalls: Array.from({ length: batch }, (_, j) => call(`call-${j}`))
},
{ content: 'done' }
])
});
const admitted = batch <= limit;
assert.equal(executed, admitted ? batch : 0, `case ${i}`);
assert.equal(result.toolCalls, admitted ? batch : 0, `case ${i}`);
assert.equal(
result.status, admitted ? 'completed' : 'tool_limit', `case ${i}`
);
if (!admitted) {
assert.deepEqual(result.messages, [{ role: 'user', content: 'test' }]);
}
}
});被允许的批次会执行所有请求的工具,而超出预算的批次则不会执行任何工具。用例标签可标识哪个生成场景失败,固定种子让我们能够复现该场景。
另外60次运行检查工具异常是否仍属于可恢复错误。这些测试使用不同错误信息重复相同的失败路径,但不使用预设随机变化:
test('工具异常仍可恢复', async () => {
let executed = 0;
for (let i = 0; i < 60; i++) {
const result = await runAgent({
prompt: 'test',
tools: [{
...noop,
async execute() {
executed++;
throw new Error(`failure ${i}`);
}
}],
model: scriptedModel([
{ content: '', toolCalls: [call('1')] },
{ content: 'recovered' }
])
});
assert.equal(result.status, 'completed');
assert.equal(result.answer, 'recovered');
assert.ok(result.messages.some(message =>
message.role === 'tool' && message.isError &&
message.content === `failure ${i}`
));
}
assert.equal(executed, 60);
});预设场景是属性风格的检查,而异常测试则反复验证特定的恢复路径。两者都不能证明正确性或替代操作系统沙箱,但都可以在不使用实时模型的情况下运行,并为我们提供关于框架行为的可重复证据。
现在,我们已将框架从简单的模型/工具循环构建为一个执行层,该层负责验证、权限、限制、上下文和遥测。模型仍会提出操作建议,而框架则负责决定实际会发生什么。
使用 npm test 运行这些示例。代码库中包含针对上述边界情况的其他针对性测试用例,以及完整的里程碑实现。
下一步:连接真实语言模型
在整个教程中,我们使用了脚本模型而不是调用真实的大语言模型。这是有意为之:确定性响应使我们能够在不进行网络调用、API密钥或不可预测模型行为的情况下测试框架,包括其失败路径。
但模型接口的设计使提供商可以互换。连接OpenAI、Anthropic或其他提供商不需要重写代理循环。我们只需要一个适配器来在提供商的API和我们现有的类型之间进行转换。
例如,使用Responses API的OpenAI适配器可能如下所示:
// src/models/openai.ts
import OpenAI from 'openai';
import type { Model } from '../core/types.ts';
export function openAIModel(client: OpenAI): Model {
return {
async generate({ messages, tools, signal }) {
const response = await client.responses.create(
{
model: 'YOUR_MODEL',
input: toOpenAIInput(messages),
tools: toOpenAITools(tools),
},
{ signal }
);
return {
content: extractText(response),
toolCalls: extractToolCalls(response),
usage: extractUsage(response),
};
},
};
}这是一个架构草图,而非完整的适配器。转换函数将把我们的消息和工具定义转换为提供商的格式,然后规范化其响应。特别是,它们必须保留工具调用ID,解析工具参数,并在后续请求中将工具结果与正确的调用关联。
适配器还会转发框架的AbortSignal,使取消操作能够传播到API请求。提供商错误会变成异常,框架已经将其作为model_error结果进行处理。
一旦适配器实现了Model接口,系统其余部分将保持不变。相同的工具注册表、参数验证、执行预算、上下文管理和可观测性功能无论哪个提供商生成下一个响应都能正常工作。
还有进一步的改进值得探索:特定提供方的令牌会计、API重试和速率限制处理、持久化的对话历史记录,以及更强的进程隔离以运行不可信代码。这些问题本身是重要的工程挑战,但它们符合我们已建立的边界,而不需要不同的架构。
完整的实现可在 07-testing 里程碑中找到。
我们构建的不只是一个将提示发送给模型并执行函数的循环。我们构建了决定允许哪些操作、可以进行多少工作、模型接收的上下文以及如何处理和观察失败的机制。
这正是带工具的语言模型与代理框架之间的本质区别。
合作伙伴推荐的课程
(在新标签页中打开)
广告
- Coursera 上的 AI 代理与模型上下文协议 由 Jules White 博士授课 5 星(20 条评价中的 4.5 星) 初级 5 周课程,每周 1 小时 证书 查看课程(在新标签页中打开)
- Coursera 上的 AI 代理与模型上下文协议 & TypeScript 由 Jules White 博士授课 初级 5 周课程,每周 1 小时 证书 查看课程(在新标签页中打开)
- Coursera 上的工具、MCP 服务器与外部集成 GitHub 中级 5 周课程,每周 2 小时 证书 查看课程(在新标签页中打开)
- edX 上的使用 TypeScript 和 GPT-5 构建有效代理 中级 2 周课程,每周 2-4 小时 证书 查看课程(在新标签页中打开)
更多关于 AI 代理与自主 AI 的课程请访问 Class Central
→
软件工程师、开源贡献者和技术作家。我撰写关于 Golang、TS/JS、算法、编码最佳实践和实际工程解决方案的内容。
如果你读到这里,请感谢作者以表达你的支持。说声谢谢
免费学习编程。freeCodeCamp 的开源课程已帮助超过 40,000 人成为开发者。立即开始