用 Go 手撸 Agent 框架 #9:把最小 Agent 抽成 Runtime

0 阅读11分钟

上一篇,我们把前面学到的模型调用、工具契约和执行循环收进一份最小代码,让 Agent 跑了起来。

它会把问题发给模型,会读 tool_calls,会在本地执行工具,也会把结果带着 tool_call_id 送回去。

用户问题
  -> 模型返回 tool_calls
  -> 本地执行工具
  -> 回填 tool_result
  -> 模型继续判断
  -> 最终回答

那份代码不多,每一步都看得见。可是准备用同一条 loop 再写一个 Agent 时,新问题出现了:

如果现在再写一个研究 Agent,我要复制多少代码?

按第 8 篇的写法,几乎要把整个 loop 再复制一遍。

第二个 Agent 还没写,重复已经出现了

第 8 篇的 main.go 同时管着几件事:

  • 用 OpenAI 兼容 SDK 组装消息。
  • 把工具定义发给模型。
  • switch 找到本地工具实现。
  • 执行工具并回填结果。
  • 判断是否继续下一轮。

对上一篇的目标来说,这样写没问题。当时需要的是看清 loop,而不是提前确定一套框架结构。

还没看清执行过程,就先造一堆接口,很容易把自己绕进去。

但当第二个 Agent 准备出现时,问题就变了。客服 Agent、研究 Agent 和代码 Agent 的工具不同、system prompt 不同,可它们都要重复同一条执行循环。

现在我们有了一个可以实际检查的重复问题,可以开始尝试抽象。

这一篇只做一件事:

把每个 Agent 都会重复的部分收起来,让业务代码只需要提供模型、工具和任务。

这一版先把收起来的部分叫作 Runtime。名字不是结论,重点是看它能不能真的减少重复,又没有把业务细节塞进公共 loop。

先把模型 SDK 从 loop 里拿走

第 8 篇的 loop 直接使用 openai.ChatCompletionMessage,也直接调用:

client.CreateChatCompletion(...)

这样最直接,但 loop 也因此认识了太多东西:它知道 SDK 的消息结构,知道 tool_calls 怎么表示,还知道工具结果应该怎么填。

这里有两种直接的做法:继续让 loop 使用 SDK 类型,或者在中间加一层自己的消息和模型接口。

第一种代码更少,如果项目确定只用一个 SDK,完全可以继续保留。这个系列还想继续尝试不同模型,所以这一版选第二种,先定义 Runtime 自己认识的消息。

所以先定义 Runtime 自己认识的消息:

type Message struct {
    Role    string
    Content []ContentBlock
}

这里没有把 Content 写成一个字符串,因为 Agent 的消息里不只有文本:

text        普通文本
tool_use    模型想调用某个工具
tool_result 本地执行后的结果

当前代码用一个 ContentBlock 接口承载这三种内容:

type ContentBlock interface {
    Type() string
    ID() string
    Name() string
    Input() json.RawMessage
    Text() string
    IsError() bool
}

这不是一个值得炫耀的“完美接口”。文本 block 并不需要 Name(),工具调用 block 也不需要 IsError()。它只是当前教学实现里的朴素折中:先让 loop 能用同一种方式读取不同消息,不在这一篇继续扩展类型设计。

接着把模型调用收在 ModelProvider 后面:

type ModelProvider interface {
    Chat(ctx context.Context, req *ChatRequest) (*ChatResponse, error)
    CountTokens(ctx context.Context, messages []Message) (int, error)
}

现在 Agent.Run 只和 Runtime 的消息打交道。DeepSeek 如何使用 OpenAI 兼容协议,tool_calls 如何转成 tool_use,以及请求为什么要注入 thinking: disabled,都留在 DeepSeekProvider 里。

这一刀切完以后,loop 不再知道底层 SDK 的消息长什么样。

我们可以先用一个条件检查这次抽取有没有达到目标:

如果更换模型提供商时仍然要大量修改 Agent.Run,这层适配可能还需要调整。

再把工具定义和执行放回一起

第 8 篇里,一个工具其实分裂成了两半:

tools 数组          给模型看的名字和参数说明
executeTool switch  本地真正的执行逻辑

开始只有两个工具时,这很顺手。

可当我修改工具名或参数时,必须记得同时修改两处。要是定义叫 get_weather,本地分支却还在等旧名字,编译器不会救我。

这里也不只有一种解法。可以用 map[string]func(...) 保存处理函数,也可以让每个工具实现同一个接口。

这一版选接口,把“怎么告诉模型”和“本地怎么执行”放在同一个 Tool 上:

type Tool interface {
    Definition() ToolDefinition
    Execute(ctx context.Context, input json.RawMessage) (string, error)
    Permission() PermissionLevel
}

Permission() 在第 9 篇还不是主角。共享的 agent 包会被后续章节继续扩展,所以当前代码里已经能看到它。本章的天气和计算工具都嵌入 BaseTool,使用默认权限,暂时不需要为它分心。

以天气工具为例:

type weatherTool struct {
    agent.BaseTool
}

func (t *weatherTool) Definition() agent.ToolDefinition {
    return agent.ToolDefinition{
        Name:        "get_weather",
        Description: "获取指定城市当前的 mock 天气信息",
        InputSchema: map[string]any{
            "type": "object",
            // 省略 properties
        },
    }
}

完整代码中,同一个 weatherTool 还实现了 Execute,负责解析城市并返回 mock 天气。

这不是为了追求“万物皆接口”。它只解决一个已经发生的问题:工具的名字、参数和执行不要再散落在两个地方。

用注册表替换 switch

工具变成一个对象以后,还差一个能按名字找到它的地方:

type ToolRegistry struct {
    tools []Tool
    index map[string]Tool
}

启动时注册:

registry := agent.NewToolRegistry()
registry.Register(&weatherTool{})
registry.Register(&calculatorTool{})

模型返回 get_weather 时,Runtime 只需要查注册表:

tool, err := registry.Get(block.Name())

替换以后,增加工具不再需要进入 Agent loop 添加 case。loop 只处理“模型要使用工具”,具体是天气、搜索还是数据库查询,由注册表回答。

于是又有了一个可以直接验证的标准:

如果新增一个普通工具时不需要修改 Agent.Run,注册表就达到了这次重构的目标。

工具不一定都写在当前程序里

第 8 篇留下过一个问题:通用 Agent 难道要提前写完所有工具吗?

走到注册表这里,这个问题可以再往前回答一步。ToolRegistry 管的是 Runtime 当前能调用的工具,但这些工具不一定都由我们在这个 Go 程序里实现。

天气和计算器是本地工具,定义和执行代码都在当前进程里。以后如果要接 GitHub、数据库或浏览器,也可以由另一个进程甚至另一台机器提供工具。当前程序只需要拿到它们的名称、参数说明和调用方式,再把它们放到模型可见的工具列表中。

本地 Tool ─────┐
               ├─> ToolRegistry ─> Runtime ─> 模型
外部 Tool ─────┘

MCP(Model Context Protocol)解决的就是这类连接问题。可以先把它理解成一套通用的工具接入协议:MCP Server 暴露工具,Agent 一侧的 MCP Client 发现并调用这些工具。这样,一个 GitHub MCP Server 可以被不同的 Agent 使用,不必每个项目都重新写一遍 GitHub 接口。

这里还容易把 MCP 和 Skill 混在一起。两者解决的不是同一个问题:

  • MCP 主要回答“工具从哪里来、怎么调用”。
  • Skill 主要回答“面对某类任务,应该怎样组合已有工具和步骤”。

比如 MCP 可以提供 search_issuescreate_issue;一个“整理线上故障”的 Skill 则可能告诉 Agent:先搜索相似问题,再读取日志,最后生成草稿,未经确认不要创建 Issue。Skill 本身不会凭空增加访问 GitHub 的能力,真正的能力仍然来自工具和权限。

这一篇先不接 MCP。不是已经认定它不属于 Runtime,而是当前只有两个本地工具,还看不出协议接入会给代码带来什么。下一篇先用这个 Runtime 做一个真实应用;再往后一篇,我们专门把本地工具、MCP 和 Skill 接到一起看。

再把剩下的循环收进 Agent.Run

模型适配和工具分发被拿走以后,执行循环变得很纯粹:

接收用户消息
  -> 调用 ModelProvider
  -> 判断是否返回 tool_use
  -> 从 ToolRegistry 查找并执行
  -> 回填 tool_result
  -> 继续调用模型,或返回最终文本

它被收进这个接口:

type Agent interface {
    Run(ctx context.Context, req Request) (*Response, error)
}

第 8 篇的业务代码,大致是这样:

for i := 0; i < maxIterations; i++ {
    // 调用模型
    // 解析 tool_calls
    // switch 执行工具
    // 回填工具结果
    // 判断是否结束
}

第 9 篇的业务代码变成了:

runtime := agent.NewAgent(provider,
    agent.WithToolRegistry(registry),
    agent.WithSystemPrompt(systemPrompt),
    agent.WithMemory(agent.NewInMemoryMemory(110000)),
    agent.WithMaxIterations(5),
    agent.WithMaxTokens(2048),
)

resp, err := runtime.Run(ctx, agent.Request{
    Message: "北京和上海天气分别怎么样?顺便算一下 (15+27)*3",
})

从对比里能看到这次抽取的直接结果:业务入口不再自己维护 loop。

它没有让 Agent 变得更聪明,也没有让 demo 增加新能力。这一步只是在尝试解决复用问题。等下一篇真正换一个任务,我们才能继续检查这个 Runtime 是否抽对了边界。

Memory 为什么只露一下脸

Runtime 里还有一个 Memory 接口,这一版先用它管理 token 预算和上下文压缩。对话历史本身由调用方保存,再通过 Request.History 传给 Runtime。

第 9 篇的 demo 使用:

agent.NewInMemoryMemory(110000)

它会用粗略 token 估算判断是否超出预算,超出后按完整对话轮次删除较早内容。第 5、6 篇已经讨论过短期记忆、token 预算和 RAG,这里只复习它和 Runtime 的连接方式。

这一版先把上下文管理放进 Runtime,因为它也会随 loop 一起被不同 Agent 重复使用。如果后面出现某些任务需要完全不同的记忆策略,这个边界仍然可以再调整。

跑一下,确认能力没有被重构弄丢

进入配套代码目录:

cd 09-agent-runtime

在当前目录创建 .env

DEEPSEEK_API_KEY=...

这个配置和第 8 篇相同,因为两个 demo 都会真实调用 DeepSeek API。

运行固定 demo:

go run . demo

也可以进入交互式 CLI:

go run .

固定 demo 仍然是第 8 篇的问题:

北京和上海天气分别怎么样?顺便算一下 (15+27)*3

这是故意的。

重构前后的能力应该一样。我们验证的不是“新能力”,而是下面三件事:

  1. 业务入口不再自己维护 Agent loop。
  2. 新增工具不需要修改 Agent.Run
  3. 更换模型适配器不需要修改 Agent.Run

如果这三条成立,这次重构就完成了。

这还不是一个完整的 Agent 框架

写到这里,我能确认的只是:第 8 篇那条 loop 已经可以被第二个应用复用。它以后还会遇到什么问题、需要长出什么结构,现在下结论仍然太早。

这一篇的问题只有一个:

一条已经跑通的 Agent loop,怎么变成可复用的 Runtime?

下一步先拿它写研究助手。只有真的用一次,才知道这个 Runtime 是能继续复用,还是只把第 8 篇的代码换了几个名字。

这一步暂时得到了什么

第 8 篇搭出了一个能运行的 Agent,也让我们看见了 loop 的原始形态。

第 9 篇没有增加新能力,只是把 loop 里的变化分开了:

  • 模型协议的变化,留给 ModelProvider
  • 工具的变化,留给 ToolToolRegistry
  • 消息和上下文,交给 MessageMemory
  • 那条不希望每次重写的执行循环,留在 Agent.Run

到这里,我们想搭的小架子已经有了基本形状。但它还只跑过天气和计算器这一个 demo,所以现在还不能说这些边界就抽对了。下一步应该换一个任务,看看它能不能在不重写 loop 的情况下继续工作。

这一轮尝试暂时给了我们一个做法:

先让一条具体的 loop 跑起来,再在复用时根据已经出现的重复尝试抽取边界。

如果你现在也有一个全写在 main.go 里的 Agent,不用急着给它加 Chain 或多 Agent。

先试着加第二个工具,再试着写第二个 Agent。

它们会帮你看到哪些代码随任务变化,哪些代码在重复。这个区分比我们现在给出的任何框架名字都更重要。