上一篇我给 TypeScript Agent 加了 context compaction。

那一层解决的是 messages 越来越长的问题:旧历史不能一直原样塞给模型,需要压成任务摘要,再保留最近几轮消息。

继续往下写时,我遇到的是另一个问题:Agent Loop 里有很多代码其实只适配 OpenAI-compatible 的 Chat Completions 格式。

比如这几个地方:

txt
tools[].function.parameters
message.tool_calls
function.arguments
role: "tool"
tool_call_id

只接 OpenAI-compatible 模型时,这样写很直接。但我后面还想对照 Anthropic。它的工具定义、工具调用返回、工具结果消息都不是这个形状。

如果这些差异都塞进 Agent Loop,主循环会越来越像一堆协议分支。每次换模型,注意力都会从 agent 行为偏到消息格式。

所以这一篇只处理一件事:抽一层 Provider Adapter。

我现在的版本在 kk-agent-labsrc/lv6-provider.ts

txt
用户任务
-> Agent Loop
-> provider.chat(messages, tools)
-> Provider 转成具体模型 API 格式
-> Provider 把响应转回统一格式
-> Agent Loop 执行工具
-> provider.makeToolResultMessage(callId, result)
-> 继续下一轮

问题从哪来#

前几篇里,Agent Loop 默认面对的是 OpenAI-compatible 格式。

工具定义长这样:

ts
{
  type: 'function',
  function: {
    name: 'read_file',
    description: '读取本地文件内容',
    parameters: {
      type: 'object',
      properties: {
        path: { type: 'string' },
      },
      required: ['path'],
    },
  },
}

模型要调用工具时,返回的是 message.tool_calls

工具参数放在 function.arguments 里,而且是 JSON 字符串:

ts
{
  id: 'call_abc',
  type: 'function',
  function: {
    name: 'read_file',
    arguments: '{"path":"./package.json"}',
  },
}

工具执行完以后,还要把结果放回 messages

ts
messages.push({
  role: 'tool',
  tool_call_id: 'call_abc',
  content: fileContent,
})

这些字段在前几篇里都很自然,因为代码本来就是按 OpenAI-compatible 写的。

但到了 Anthropic 这一侧,system、工具定义、工具调用和工具结果都要换一种表达。

在我这版 AnthropicProvider 里,主要处理这几类转换:

txt
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

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 只传 callIdresult,至于最后要变成什么协议格式,由 provider 自己处理。

这里的一个小细节是 assistantMessage

前几篇已经踩过这个坑:模型发起工具调用的那条 assistant 消息必须写回 messages。如果只写工具结果,下一轮模型会看到一个没有来源的 tool result。

所以 provider 返回工具调用时,除了 calls,还要返回一条可以保存进历史的 assistantMessage

OpenAICompatibleProvider 做什么#

OpenAI-compatible 的 provider 最薄。

因为内部格式本来就接近它要的请求格式,所以它主要做三件事:

  1. 把统一的 ToolDefinition 包成 Chat Completions 要的 tools
  2. 请求模型
  3. message.tool_calls 解析成统一的 ToolCallRequest

核心代码大概是这样:

ts
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 解析出来:

ts
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 }
  })

最后返回统一格式:

ts
return {
  type: 'tool_calls',
  calls,
  assistantMessage: {
    role: 'assistant',
    content: message.content ?? null,
    tool_calls: message.tool_calls,
  },
}

工具结果也很直接:

ts
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 里是这样处理的:

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

ts
const anthropicTools: Anthropic.Tool[] = tools.map(t => ({
  name: t.name,
  description: t.description,
  input_schema: t.parameters as Anthropic.Tool['input_schema'],
}))

请求模型时,system 改成单独参数传入:

ts
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

ts
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_useinput_schematool_result 了。

它拿到的永远是:

ts
{
  type: 'tool_calls',
  calls: [
    {
      id: '...',
      name: 'read_file',
      arguments: { path: './package.json' },
    },
  ],
  assistantMessage,
}

Agent Loop 变薄#

有了 Provider 以后,runAgent 里最重要的代码反而变少了。

它改成调用统一的 provider.chat

ts
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:

ts
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 里跑:

bash
npm run lv6

如果要走 Anthropic:

bash
npm run lv6:anthropic

这两个命令跑的是同一个 Agent Loop。区别只在 provider。

Trace 也顺手加上#

这一版我还加了一个很小的 trace。

它记录每一轮是文字回答还是工具调用,工具名、参数、结果预览和耗时是多少。

类型大概是这样:

ts
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 已经有了几层基础能力:

txt
tool use
agent loop
streaming
context compaction
provider adapter
trace

现在它不再只绑定一种模型协议。

但改完 provider 以后,还有一个问题:我怎么知道自己没有把 Agent 改坏?

手动跑一次 demo 不够。Agent 行为有随机性,同一个任务不同模型也可能走不同路径。

所以下一篇我准备写 eval runner。

也就是把“读取 package.json”“列目录”“执行 echo”“读不存在的文件”这些固定任务收成测试集。每次改工具、改 prompt、改 provider,都跑一遍,至少能确认基础能力没有退化。