上一篇我给 TypeScript Agent 加了 context compaction。
那一层解决的是 messages 越来越长的问题:旧历史不能一直原样塞给模型,需要压成任务摘要,再保留最近几轮消息。
继续往下写时,我遇到的是另一个问题:Agent Loop 里有很多代码其实只适配 OpenAI-compatible 的 Chat Completions 格式。
比如这几个地方:
tools[].function.parameters
message.tool_calls
function.arguments
role: "tool"
tool_call_id只接 OpenAI-compatible 模型时,这样写很直接。但我后面还想对照 Anthropic。它的工具定义、工具调用返回、工具结果消息都不是这个形状。
如果这些差异都塞进 Agent Loop,主循环会越来越像一堆协议分支。每次换模型,注意力都会从 agent 行为偏到消息格式。
所以这一篇只处理一件事:抽一层 Provider Adapter。
我现在的版本在 kk-agent-lab 的 src/lv6-provider.ts:
用户任务
-> Agent Loop
-> provider.chat(messages, tools)
-> Provider 转成具体模型 API 格式
-> Provider 把响应转回统一格式
-> Agent Loop 执行工具
-> provider.makeToolResultMessage(callId, result)
-> 继续下一轮问题从哪来#
前几篇里,Agent Loop 默认面对的是 OpenAI-compatible 格式。
工具定义长这样:
{
type: 'function',
function: {
name: 'read_file',
description: '读取本地文件内容',
parameters: {
type: 'object',
properties: {
path: { type: 'string' },
},
required: ['path'],
},
},
}模型要调用工具时,返回的是 message.tool_calls。
工具参数放在 function.arguments 里,而且是 JSON 字符串:
{
id: 'call_abc',
type: 'function',
function: {
name: 'read_file',
arguments: '{"path":"./package.json"}',
},
}工具执行完以后,还要把结果放回 messages:
messages.push({
role: 'tool',
tool_call_id: 'call_abc',
content: fileContent,
})这些字段在前几篇里都很自然,因为代码本来就是按 OpenAI-compatible 写的。
但到了 Anthropic 这一侧,system、工具定义、工具调用和工具结果都要换一种表达。
在我这版 AnthropicProvider 里,主要处理这几类转换:
system message
-> 独立的 system 参数
tools[].function.parameters
-> tools[].input_schema
message.tool_calls
-> content block: { type: "tool_use" }
function.arguments
-> input 对象
role: "tool"
-> role: "user" + content block: { type: "tool_result" }这些差异不应该散落在 Agent Loop 里。Agent Loop 关心的是“模型要不要调用工具”,不是“某个厂商把工具结果叫 tool_result 还是 tool_call_id”。
先定内部格式#
这一版我没有重新设计一套完全中立的消息结构。
更省事的做法是:内部继续沿用前几篇已经跑通的 OpenAI-compatible messages 形状。
原因很现实:
- 前面的
messages.push(...)逻辑已经是这个形状 - 工具定义也已经是 JSON Schema 风格
- DeepSeek、GLM、很多网关都兼容 OpenAI 格式
- 这篇的重点是隔离协议差异,不是重写所有历史状态
所以我先写了一个统一接口。
代码在 src/providers/openai-compatible.ts:
export interface ToolDefinition {
name: string
description: string
parameters: Record<string, unknown>
}
export interface ToolCallRequest {
id: string
name: string
arguments: Record<string, string>
}
export type ChatOutput =
| {
type: 'text'
content: string
assistantMessage: OpenAI.Chat.Completions.ChatCompletionMessageParam
}
| {
type: 'tool_calls'
calls: ToolCallRequest[]
assistantMessage: OpenAI.Chat.Completions.ChatCompletionMessageParam
}
export interface LLMProvider {
chat(
messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[],
tools: ToolDefinition[]
): Promise<ChatOutput>
makeToolResultMessage(
callId: string,
result: string
): OpenAI.Chat.Completions.ChatCompletionMessageParam
}这个接口把两件事包起来了。
第一,chat 负责请求模型,并把响应转成统一的 ChatOutput。
第二,makeToolResultMessage 负责生成工具结果消息。Agent Loop 只传 callId 和 result,至于最后要变成什么协议格式,由 provider 自己处理。
这里的一个小细节是 assistantMessage。
前几篇已经踩过这个坑:模型发起工具调用的那条 assistant 消息必须写回 messages。如果只写工具结果,下一轮模型会看到一个没有来源的 tool result。
所以 provider 返回工具调用时,除了 calls,还要返回一条可以保存进历史的 assistantMessage。
OpenAICompatibleProvider 做什么#
OpenAI-compatible 的 provider 最薄。
因为内部格式本来就接近它要的请求格式,所以它主要做三件事:
- 把统一的
ToolDefinition包成 Chat Completions 要的tools - 请求模型
- 把
message.tool_calls解析成统一的ToolCallRequest
核心代码大概是这样:
const openAITools: OpenAI.Chat.Completions.ChatCompletionTool[] = tools.map(t => ({
type: 'function',
function: {
name: t.name,
description: t.description,
parameters: t.parameters,
},
}))
const res = await this.client.chat.completions.create({
model: this.model,
messages,
tools: openAITools.length > 0 ? openAITools : undefined,
tool_choice: openAITools.length > 0 ? 'auto' : undefined,
})如果模型请求工具,就把 function.arguments 解析出来:
const calls: ToolCallRequest[] = message.tool_calls
.filter(tc => tc.type === 'function')
.map(tc => {
const fn = (tc as { function: { name: string; arguments: string } }).function
let args: Record<string, string> = {}
try {
const parsed = JSON.parse(fn?.arguments || '{}')
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
args = parsed as Record<string, string>
}
} catch {
// keep args as {}
}
return { id: tc.id, name: fn?.name ?? '', arguments: args }
})最后返回统一格式:
return {
type: 'tool_calls',
calls,
assistantMessage: {
role: 'assistant',
content: message.content ?? null,
tool_calls: message.tool_calls,
},
}工具结果也很直接:
makeToolResultMessage(callId: string, result: string) {
return { role: 'tool', tool_call_id: callId, content: result }
}这一层看起来有点多余,但它给后面的 AnthropicProvider 留出了同一个接口。
AnthropicProvider 做什么#
AnthropicProvider 的工作量主要在格式转换。
发送请求之前,它要把内部 messages 转成 Anthropic 的 messages。
我在 src/providers/anthropic-compatible.ts 里是这样处理的:
const anthropicMessages: AnthropicMessage[] = []
for (const msg of messages) {
if (msg.role === 'system') continue
if (msg.role === 'user') {
anthropicMessages.push({
role: 'user',
content: String(msg.content ?? ''),
})
} else if (msg.role === 'assistant') {
const openAIMsg = msg as OpenAI.Chat.Completions.ChatCompletionAssistantMessageParam
if (openAIMsg.tool_calls && openAIMsg.tool_calls.length > 0) {
const contentBlocks: Anthropic.ContentBlock[] = []
for (const tc of openAIMsg.tool_calls) {
if (tc.type !== 'function') continue
contentBlocks.push({
type: 'tool_use',
id: tc.id,
name: tc.function.name,
input: JSON.parse(tc.function.arguments) as Record<string, unknown>,
} as Anthropic.ContentBlock)
}
anthropicMessages.push({ role: 'assistant', content: contentBlocks })
}
} else if (msg.role === 'tool') {
const toolMsg = msg as OpenAI.Chat.Completions.ChatCompletionToolMessageParam
anthropicMessages.push({
role: 'user',
content: [{
type: 'tool_result',
tool_use_id: toolMsg.tool_call_id,
content: String(toolMsg.content ?? ''),
}],
})
}
}这里最容易看错的是最后一段。
内部消息里是 role: 'tool'。发给 Anthropic 前,要变成一条 role: 'user' 消息,里面放 tool_result block。
工具定义也要从 parameters 转成 input_schema:
const anthropicTools: Anthropic.Tool[] = tools.map(t => ({
name: t.name,
description: t.description,
input_schema: t.parameters as Anthropic.Tool['input_schema'],
}))请求模型时,system 改成单独参数传入:
const res = await this.client.messages.create({
model: this.model,
max_tokens: 4096,
system: this.systemPrompt || undefined,
messages: anthropicMessages,
tools: anthropicTools.length > 0 ? anthropicTools : undefined,
})响应回来以后,再把 tool_use 转回统一的 ToolCallRequest:
if (res.stop_reason === 'tool_use') {
const calls: ToolCallRequest[] = res.content
.filter((block): block is Anthropic.ToolUseBlock => block.type === 'tool_use')
.map(block => ({
id: block.id,
name: block.name,
arguments: block.input as Record<string, string>,
}))
const assistantMessage: OpenAI.Chat.Completions.ChatCompletionMessageParam = {
role: 'assistant',
content: null,
tool_calls: calls.map(c => ({
id: c.id,
type: 'function' as const,
function: {
name: c.name,
arguments: JSON.stringify(c.arguments),
},
})),
}
return { type: 'tool_calls', calls, assistantMessage }
}这段代码做完以后,Agent Loop 就不用知道 Anthropic 的 tool_use、input_schema 和 tool_result 了。
它拿到的永远是:
{
type: 'tool_calls',
calls: [
{
id: '...',
name: 'read_file',
arguments: { path: './package.json' },
},
],
assistantMessage,
}Agent Loop 变薄#
有了 Provider 以后,runAgent 里最重要的代码反而变少了。
它改成调用统一的 provider.chat:
const result = await provider.chat(messages, toolDefs)
if (result.type === 'text') {
messages.push(result.assistantMessage)
return { answer: result.content, trace }
}
messages.push(result.assistantMessage)
for (const call of result.calls) {
const handler = toolHandlers[call.name]
const toolResult = handler
? handler(call.arguments)
: `[error] 未知工具: ${call.name}`
messages.push(provider.makeToolResultMessage(call.id, toolResult))
}这就是我想要的边界。
Agent Loop 只负责这几件事:
- 保存
messages - 请求模型
- 判断文字回答还是工具调用
- 执行本地工具
- 把工具结果写回历史
- 控制最大轮数
模型协议的差异留在 Provider 里面。
这个改动不让 Agent 立刻变聪明,但会让后面的修改更稳。因为主循环不用跟着每个模型厂商一起变。
用环境变量切换 Provider#
lv6-provider.ts 里用一个环境变量决定 provider:
const providerName = process.env.PROVIDER ?? 'openai'
if (providerName === 'anthropic') {
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY })
const model = process.env.ANTHROPIC_MODEL ?? 'claude-sonnet-4-5'
provider = new AnthropicProvider(client, model, systemPrompt)
} else {
const client = new OpenAI({
apiKey: process.env.LLM_API_KEY,
baseURL: process.env.LLM_BASE_URL,
})
const model = process.env.LLM_MODEL!
provider = new OpenAICompatibleProvider(client, model)
}在 kk-agent-lab 里跑:
npm run lv6如果要走 Anthropic:
npm run lv6:anthropic这两个命令跑的是同一个 Agent Loop。区别只在 provider。
Trace 也顺手加上#
这一版我还加了一个很小的 trace。
它记录每一轮是文字回答还是工具调用,工具名、参数、结果预览和耗时是多少。
类型大概是这样:
interface TraceRound {
round: number
type: 'text' | 'tool_calls'
toolCalls?: Array<{
name: string
args: Record<string, string>
resultPreview: string
durationMs: number
}>
durationMs: number
error?: string
}调 Agent 时,我现在会先看 trace,再改 prompt。
如果工具参数错了,通常要先看 schema 描述是不是不清楚。
如果工具结果为空,要先看工具实现。
如果只在某个 provider 下失败,就去看 adapter 的转换。
如果模型根本没有调工具,再回头看 system prompt。
这个顺序能少做很多无效修改。
这版还有哪些边界#
这一版只是把 provider adapter 跑通,还不是一个完整的 provider system。
几个限制比较明显。
第一,内部状态仍然是 OpenAI-compatible messages。这样迁移成本低,但它不是最中立的内部协议。如果后面要支持更多模型能力,可能需要再抽一层自己的 AgentMessage。
第二,ToolCallRequest.arguments 现在写成了 Record<string, string>。对简单文件工具够用,但复杂工具会有 number、boolean、array,类型应该再放宽。
第三,这一篇处理的是非流式 provider。前面 lv4-stream.ts 解决过 OpenAI-compatible 的 streaming tool calls,但 Anthropic 的 streaming 事件又是另一套形状。后面如果要统一流式输出,还要再做一层流式事件适配。
第四,provider adapter 只能隔离协议差异,不能保证模型行为一致。两个模型拿到同一个任务,可能走不同工具路径。这个问题需要 eval 来管。
下一步#
到这里,这个 TypeScript Agent 已经有了几层基础能力:
tool use
agent loop
streaming
context compaction
provider adapter
trace现在它不再只绑定一种模型协议。
但改完 provider 以后,还有一个问题:我怎么知道自己没有把 Agent 改坏?
手动跑一次 demo 不够。Agent 行为有随机性,同一个任务不同模型也可能走不同路径。
所以下一篇我准备写 eval runner。
也就是把“读取 package.json”“列目录”“执行 echo”“读不存在的文件”这些固定任务收成测试集。每次改工具、改 prompt、改 provider,都跑一遍,至少能确认基础能力没有退化。