上一篇,我们把前面学到的模型调用、工具契约和执行循环收进一份最小代码,让 Agent 跑了起来。
它会把问题发给模型,会读 tool_calls,会在本地执行工具,也会把结果带着 tool_call_id 送回去。
text
用户问题
-> 模型返回 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,也直接调用:
go
client.CreateChatCompletion(...)
这样最直接,但 loop 也因此认识了太多东西:它知道 SDK 的消息结构,知道 tool_calls 怎么表示,还知道工具结果应该怎么填。
这里有两种直接的做法:继续让 loop 使用 SDK 类型,或者在中间加一层自己的消息和模型接口。
第一种代码更少,如果项目确定只用一个 SDK,完全可以继续保留。这个系列还想继续尝试不同模型,所以这一版选第二种,先定义 Runtime 自己认识的消息。
所以先定义 Runtime 自己认识的消息:
go
type Message struct {
Role string
Content []ContentBlock
}
这里没有把 Content 写成一个字符串,因为 Agent 的消息里不只有文本:
text
text 普通文本
tool_use 模型想调用某个工具
tool_result 本地执行后的结果
当前代码用一个 ContentBlock 接口承载这三种内容:
go
type ContentBlock interface {
Type() string
ID() string
Name() string
Input() json.RawMessage
Text() string
IsError() bool
}
这不是一个值得炫耀的"完美接口"。文本 block 并不需要 Name(),工具调用 block 也不需要 IsError()。它只是当前教学实现里的朴素折中:先让 loop 能用同一种方式读取不同消息,不在这一篇继续扩展类型设计。
接着把模型调用收在 ModelProvider 后面:
go
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 篇里,一个工具其实分裂成了两半:
text
tools 数组 给模型看的名字和参数说明
executeTool switch 本地真正的执行逻辑
开始只有两个工具时,这很顺手。
可当我修改工具名或参数时,必须记得同时修改两处。要是定义叫 get_weather,本地分支却还在等旧名字,编译器不会救我。
这里也不只有一种解法。可以用 map[string]func(...) 保存处理函数,也可以让每个工具实现同一个接口。
这一版选接口,把"怎么告诉模型"和"本地怎么执行"放在同一个 Tool 上:
go
type Tool interface {
Definition() ToolDefinition
Execute(ctx context.Context, input json.RawMessage) (string, error)
Permission() PermissionLevel
}
Permission() 在第 9 篇还不是主角。共享的 agent 包会被后续章节继续扩展,所以当前代码里已经能看到它。本章的天气和计算工具都嵌入 BaseTool,使用默认权限,暂时不需要为它分心。
以天气工具为例:
go
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
工具变成一个对象以后,还差一个能按名字找到它的地方:
go
type ToolRegistry struct {
tools []Tool
index map[string]Tool
}
启动时注册:
go
registry := agent.NewToolRegistry()
registry.Register(&weatherTool{})
registry.Register(&calculatorTool{})
模型返回 get_weather 时,Runtime 只需要查注册表:
go
tool, err := registry.Get(block.Name())
替换以后,增加工具不再需要进入 Agent loop 添加 case。loop 只处理"模型要使用工具",具体是天气、搜索还是数据库查询,由注册表回答。
于是又有了一个可以直接验证的标准:
如果新增一个普通工具时不需要修改
Agent.Run,注册表就达到了这次重构的目标。
工具不一定都写在当前程序里
第 8 篇留下过一个问题:通用 Agent 难道要提前写完所有工具吗?
走到注册表这里,这个问题可以再往前回答一步。ToolRegistry 管的是 Runtime 当前能调用的工具,但这些工具不一定都由我们在这个 Go 程序里实现。
天气和计算器是本地工具,定义和执行代码都在当前进程里。以后如果要接 GitHub、数据库或浏览器,也可以由另一个进程甚至另一台机器提供工具。当前程序只需要拿到它们的名称、参数说明和调用方式,再把它们放到模型可见的工具列表中。
text
本地 Tool ─────┐
├─> ToolRegistry ─> Runtime ─> 模型
外部 Tool ─────┘
MCP(Model Context Protocol)解决的就是这类连接问题。可以先把它理解成一套通用的工具接入协议:MCP Server 暴露工具,Agent 一侧的 MCP Client 发现并调用这些工具。这样,一个 GitHub MCP Server 可以被不同的 Agent 使用,不必每个项目都重新写一遍 GitHub 接口。
这里还容易把 MCP 和 Skill 混在一起。两者解决的不是同一个问题:
- MCP 主要回答"工具从哪里来、怎么调用"。
- Skill 主要回答"面对某类任务,应该怎样组合已有工具和步骤"。
比如 MCP 可以提供 search_issues 和 create_issue;一个"整理线上故障"的 Skill 则可能告诉 Agent:先搜索相似问题,再读取日志,最后生成草稿,未经确认不要创建 Issue。Skill 本身不会凭空增加访问 GitHub 的能力,真正的能力仍然来自工具和权限。
这一篇先不接 MCP。不是已经认定它不属于 Runtime,而是当前只有两个本地工具,还看不出协议接入会给代码带来什么。下一篇先用这个 Runtime 做一个真实应用;再往后一篇,我们专门把本地工具、MCP 和 Skill 接到一起看。
再把剩下的循环收进 Agent.Run
模型适配和工具分发被拿走以后,执行循环变得很纯粹:
text
接收用户消息
-> 调用 ModelProvider
-> 判断是否返回 tool_use
-> 从 ToolRegistry 查找并执行
-> 回填 tool_result
-> 继续调用模型,或返回最终文本
它被收进这个接口:
go
type Agent interface {
Run(ctx context.Context, req Request) (*Response, error)
}
第 8 篇的业务代码,大致是这样:
go
for i := 0; i < maxIterations; i++ {
// 调用模型
// 解析 tool_calls
// switch 执行工具
// 回填工具结果
// 判断是否结束
}
第 9 篇的业务代码变成了:
go
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 使用:
go
agent.NewInMemoryMemory(110000)
它会用粗略 token 估算判断是否超出预算,超出后按完整对话轮次删除较早内容。第 5、6 篇已经讨论过短期记忆、token 预算和 RAG,这里只复习它和 Runtime 的连接方式。
这一版先把上下文管理放进 Runtime,因为它也会随 loop 一起被不同 Agent 重复使用。如果后面出现某些任务需要完全不同的记忆策略,这个边界仍然可以再调整。
跑一下,确认能力没有被重构弄丢
进入配套代码目录:
bash
cd 09-agent-runtime
在当前目录创建 .env:
text
DEEPSEEK_API_KEY=...
这个配置和第 8 篇相同,因为两个 demo 都会真实调用 DeepSeek API。
运行固定 demo:
bash
go run . demo
也可以进入交互式 CLI:
bash
go run .
固定 demo 仍然是第 8 篇的问题:
text
北京和上海天气分别怎么样?顺便算一下 (15+27)*3
这是故意的。
重构前后的能力应该一样。我们验证的不是"新能力",而是下面三件事:
- 业务入口不再自己维护 Agent loop。
- 新增工具不需要修改
Agent.Run。 - 更换模型适配器不需要修改
Agent.Run。
如果这三条成立,这次重构就完成了。
这还不是一个完整的 Agent 框架
写到这里,我能确认的只是:第 8 篇那条 loop 已经可以被第二个应用复用。它以后还会遇到什么问题、需要长出什么结构,现在下结论仍然太早。
这一篇的问题只有一个:
一条已经跑通的 Agent loop,怎么变成可复用的 Runtime?
下一步先拿它写研究助手。只有真的用一次,才知道这个 Runtime 是能继续复用,还是只把第 8 篇的代码换了几个名字。
这一步暂时得到了什么
第 8 篇搭出了一个能运行的 Agent,也让我们看见了 loop 的原始形态。
第 9 篇没有增加新能力,只是把 loop 里的变化分开了:
- 模型协议的变化,留给
ModelProvider。 - 工具的变化,留给
Tool和ToolRegistry。 - 消息和上下文,交给
Message和Memory。 - 那条不希望每次重写的执行循环,留在
Agent.Run。
到这里,我们想搭的小架子已经有了基本形状。但它还只跑过天气和计算器这一个 demo,所以现在还不能说这些边界就抽对了。下一步应该换一个任务,看看它能不能在不重写 loop 的情况下继续工作。
这一轮尝试暂时给了我们一个做法:
先让一条具体的 loop 跑起来,再在复用时根据已经出现的重复尝试抽取边界。
如果你现在也有一个全写在 main.go 里的 Agent,不用急着给它加 Chain 或多 Agent。
先试着加第二个工具,再试着写第二个 Agent。
它们会帮你看到哪些代码随任务变化,哪些代码在重复。这个区分比我们现在给出的任何框架名字都更重要。