走到第 8 篇,我们已经分别碰到过模型调用、ReAct、工具契约、上下文窗口和外部知识。
这些内容不是为了凑出一张框架蓝图。当时每一篇只是在解决眼前的问题:怎么调模型,怎么让它使用工具,对话变长后怎么办,模型不知道外部资料时怎么办。
到上一篇,这些认识又收束成一句话:
Agent 不是一次模型调用,而是一段可控的执行循环。
学到这个节点,很自然会冒出一个问题:
我们已经认识了这么多零件,是不是可以开始搭一个自己的 Agent 了?
先不猜它最后会长成什么样。我们先把已经学过的执行过程接起来:模型做决定,本地执行工具,工具结果再交还给模型。
所以这一篇先尝试搭一个最小 Agent:
让一条完整的 Agent loop 真正跑起来。
我们现在知道的最直接写法,就是直接使用模型 SDK,把工具定义写在当前程序里,再用 switch 执行工具。这还不像一个框架,但可以先让我们看清楚这个小架子到底需要哪些东西:
text
用户问题
-> 模型返回 tool_calls
-> 本地执行工具
-> 工具结果带 tool_call_id 回填
-> 再问模型
-> 最终回答
代码在 companion repo:
bash
cd 08-minimal-agent
这个 demo 会真实调用 DeepSeek API。先在当前目录创建 .env:
text
DEEPSEEK_API_KEY=...
再运行固定 demo:
bash
go run . demo
如果你想交互式试,也可以直接:
bash
go run .
第一版先怎么搭
先从一个具体问题开始:
text
北京和上海天气分别怎么样?
顺便算一下 (15+27)*3。
这个问题需要两种能力:查天气和做计算。模型要先判断调用哪个工具,本地代码执行完,再把结果交还给模型。
如果这条链路能跑通,我们就得到了第一个完整的小 Agent。至于它能不能变成一个可复用的小架子,先跑完再看。
第一步:准备两个工具
我们这个最小 Agent 需要有工具,因为这一步要观察的就是"模型决策---本地执行---结果回填"。
这里放两个工具:
get_weather:查询 mock 天气。calculate:计算简单四则运算。
工具定义传给模型,告诉模型:
go
var tools = []openai.Tool{
{
Type: openai.ToolTypeFunction,
Function: &openai.FunctionDefinition{
Name: "get_weather",
Description: "获取指定城市当前的 mock 天气信息",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"city": map[string]any{
"type": "string",
"description": "城市名称,例如北京、上海、东京",
},
},
"required": []string{"city"},
},
},
},
}
注意,这里只是"工具定义"。模型看到它以后,可以决定要不要调用这个工具。
但模型不会真的执行 Go 函数。
工具真正执行,还是在我们自己的代码里:
go
func executeTool(toolCall openai.ToolCall) string {
switch toolCall.Function.Name {
case "get_weather":
var args struct {
City string `json:"city"`
}
if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &args); err != nil {
return "参数解析失败: " + err.Error()
}
if weather, ok := weatherData[args.City]; ok {
return weather
}
return args.City + " 的天气数据暂时不可用"
}
return "未知工具: " + toolCall.Function.Name
}
可以这样理解:工具定义是给模型看的菜单,工具实现才是我们本地真正会执行的代码。模型只能点菜,不能替我们下厨。
通用 Agent 难道要提前写完所有工具吗
写到这里,很容易冒出一个问题:
我们现在给天气和计算分别写了工具。那 Claude Code、Codex 这类通用 Agent,难道提前写了成千上万个工具,等着覆盖用户的所有需求吗?
如果一个需求对应一个工具,确实会变成这样:
text
查天气 -> get_weather
查股票 -> get_stock
发邮件 -> send_email
查数据库 -> query_database
创建工单 -> create_ticket
但用户会提出什么问题,没人能够提前列完。
通用 Agent 通常不会只靠大量专用工具覆盖需求。它们还会提供一些覆盖面很广的基础工具,例如:
text
读取文件
搜索文件
修改文件
执行 Shell 命令
搜索网页
读取网页
控制浏览器
这里面最典型的是 Shell。
如果 Agent 有一个可以执行命令的工具,它就不需要分别拥有:
text
run_go_test
run_npm_build
show_git_diff
call_http_api
query_postgres
它可以通过同一个 Shell 工具组合出这些动作:
bash
go test ./...
npm run build
git diff
curl "https://example.com/api"
psql -c "select ..."
从模型看到的工具列表来说,可能只有一个 Bash;但 Bash 后面连接着操作系统、CLI 和项目脚本。它更像一个"元工具"。
那如果没有 get_weather,Agent 还能不能查天气?
要看它还有没有别的通道:
text
有 Web Search -> 搜索天气页面
有浏览器 -> 打开天气网站
有 Web Fetch -> 读取天气接口
有 Bash -> curl 天气 API
只要其中一条通道可用,它就可能拿到实时天气。
如果这些工具都没有,Shell 也不能联网,更没有天气 API,那么它确实做不到。模型也许知道"天气应该怎么查",但知道方法不等于拥有执行能力。
这里可以先分清四层:
text
模型知识:它知不知道可以怎么做
工具能力:系统有没有提供执行通道
运行权限:当前是否允许调用这个工具
外部条件:有没有网络、凭证和目标系统访问权
我们的 get_weather 是一个专用工具。它能力很窄,只能查三座城市的 mock 天气,但参数清楚、结果稳定,也不会碰文件或执行任意命令,很适合第一次观察工具调用。
Shell、浏览器和 Web Search 更通用,可以组合出很多行为,但能力越宽,边界也越难控制。
还有一类工具既不是当前程序里手写的,也不是 Agent 自带的。它们可能由 GitHub、数据库或公司内部系统提供,再通过一种统一方式接进来。现在常见的做法叫 MCP。
先不用展开协议细节。此时只需要有一张粗略地图:
text
内置工具
Agent 自己带着的基础能力
通用工具
Bash、Web Search、浏览器等可以组合的动作
MCP
把外部系统提供的工具接进 Agent
Skills
告诉 Agent 遇到某类任务时,怎样组织和使用已有工具
MCP 和 Skills 不是一回事。
MCP 更接近"给 Agent 增加一双手";Skill 更接近"告诉 Agent 这双手应该按什么步骤工作"。如果系统里根本没有发邮件的工具,写一份"如何发邮件"的 Skill 也不能凭空把邮件发出去。
这一篇暂时只实现天气和计算器。不是因为通用 Agent 只有这种专用工具,而是因为我们正在学习最底层的一次工具调用到底怎么发生。
第二步:把 messages 准备好
最小版本的消息很简单:
go
messages := []openai.ChatCompletionMessage{
{Role: openai.ChatMessageRoleSystem, Content: systemPrompt},
{Role: openai.ChatMessageRoleUser, Content: question},
}
这里的 systemPrompt 也很朴素:
text
你是一个最小 Agent demo。
工作方式:
1. 需要外部信息或精确计算时,先调用工具
2. 拿到工具结果后,再继续判断是否需要下一步
3. 信息足够时,给出简洁答案
4. 不要在只完成部分步骤时提前结束
它不是为了让模型"变聪明",而是给模型一个工作规则:
text
别急着回答,先判断需不需要工具。
工具结果回来以后,再决定是否继续。
这和上一篇讲的执行循环正好对应。
第三步:写出最小 loop
核心代码就是这个循环:
go
for i := 0; i < 5; i++ {
resp, err := client.CreateChatCompletion(ctx, openai.ChatCompletionRequest{
Model: "deepseek-v4-flash",
MaxTokens: 2048,
Messages: messages,
Tools: tools,
})
if err != nil {
return "", fmt.Errorf("调用模型失败: %w", err)
}
choice := resp.Choices[0]
if choice.FinishReason != openai.FinishReasonToolCalls {
return choice.Message.Content, nil
}
messages = append(messages, openai.ChatCompletionMessage{
Role: openai.ChatMessageRoleAssistant,
Content: choice.Message.Content,
ToolCalls: choice.Message.ToolCalls,
})
for _, toolCall := range choice.Message.ToolCalls {
result := executeTool(toolCall)
messages = append(messages, openai.ChatCompletionMessage{
Role: openai.ChatMessageRoleTool,
ToolCallID: toolCall.ID,
Content: result,
})
}
}
这一段代码要看三件事。
第一,模型返回 tool_calls 时,我们要把 assistant 这条消息原样放回 messages。
第二,执行工具后,要追加一条 tool 消息。
第三,这条 tool 消息必须带上 ToolCallID,让模型知道它对应的是哪一次工具调用。
如果不回填 assistant 的 tool_calls,下一轮模型不知道前面发生了什么。如果工具结果不带 tool_call_id,模型也无法把结果和调用意图对上。
可以这样理解:tool_calls 是模型发出的调用意图,tool 消息才是我们把真实执行结果还给模型。两者靠 tool_call_id 接起来。
这里还有一个容易忽略的问题:工具执行失败了,loop 还要不要继续?
这个最小版本会把参数解析失败、未知工具等错误也作为 tool 消息回填给模型。模型看到失败结果后,可以决定换参数、换工具,或者结束并向用户说明情况。
但"失败后继续 loop"不等于"所有错误都立即重试"。网络超时这类短暂错误可以有限重试;参数格式错误如果参数没变,原样重试只会再失败。实际项目中需要区分可重试和不可重试错误,限制重试次数,并保留 Agent 的最大循环次数作为最后兜底。
跑一次 demo
固定 demo 的问题是:
text
北京和上海天气分别怎么样?顺便算一下 (15+27)*3
运行:
bash
cd 08-minimal-agent
go run . demo
这里使用的仍然是前面配置的 DEEPSEEK_API_KEY。
你会看到类似这样的过程:
text
[loop 1] finish_reason=tool_calls
[tool] get_weather({"city":"北京"}) => 15°C,晴,西北风3级
[tool] get_weather({"city":"上海"}) => 18°C,多云,东南风2级
[tool] calculate({"expression":"(15+27)*3"}) => (15+27)*3 = 126
[loop 2] finish_reason=stop
北京天气是 15°C,晴,西北风3级;上海天气是 18°C,多云,东南风2级;(15+27)*3 的结果是 126。
实际输出会因为模型版本、工具调用顺序、表达式写法略有差异。这个 demo 的重点不是答案文本,而是中间过程:
text
第一轮:模型没有回答,而是发出 tool_calls
本地:我们执行工具并回填结果
第二轮:模型基于工具结果生成最终答案
这就是最小 Agent loop。
这个 demo 虽然调用了两种工具,但它们可以在同一轮由模型一次性返回多个 tool_call。也可能先执行一个工具,模型看到结果后再决定下一步。那时 loop 会多跑几轮,但"执行、回填、再决策"的主链路没有变。
为什么要限制最大循环次数
代码里有一个很普通的限制:
go
for i := 0; i < 5; i++ {
// ...
}
这是必要的。
因为模型可能因为提示词、工具结果、参数错误进入循环。比如它一直尝试调用一个不存在的工具,或者一直认为信息不够。
Agent 只要能自主决定下一步,就必须有停止条件。
这个版本先把 maxIterations 设为 5。这个数字不是什么标准,只是让 demo 在模型一直调工具时能停下来。
现在先记住一件事:
凡是循环,都要有退出条件。Agent loop 也一样。
DeepSeek Thinking 为什么先关掉
代码里还有一个小细节:对 DeepSeek 请求注入了:
json
{"thinking": {"type": "disabled"}}
这不是说 Thinking Mode 不重要。
而是本篇要讲工具调用的主链路。DeepSeek V4 在工具调用场景下如果开启 reasoning,需要正确处理并回传 reasoning_content。否则后续请求可能出错。
为了让这个最小 demo 聚焦在:
text
tool_calls -> execute tool -> tool result -> next request
我们先关掉 thinking。
本篇只需要知道:我们为了不同时处理 reasoning_content,暂时关掉了 Thinking Mode。这是当前 demo 的取舍,不代表 Agent 必须关闭推理。
这段代码哪里开始变难维护
到这里,最小 Agent 已经能跑。
但它也暴露了几个问题:
- 工具定义和工具实现分散在一起。
- 工具分发靠
switch。 - 模型调用直接写死 DeepSeek。
messages结构直接依赖 OpenAI SDK。- 每个 Agent 都要复制同一段 loop。
当前只有两个工具,switch 还很直接。但如果再写一个 Agent,这份 main.go 里哪些代码需要复制?如果再加几个工具,工具定义和 switch 会变成什么样?
这些是跑完第一版以后才看到的问题。下一步先不猜答案,而是带着这些问题继续整理代码。
这一篇先确认一件事:
最小 Agent 的核心不是工具有多少,而是能否稳定完成"模型决策 -> 本地执行 -> 结果回填 -> 再次决策"的闭环。
最后留几个自测问题:
- 模型返回
tool_calls时,工具是否已经执行? - 为什么 assistant 的
ToolCalls消息要放回messages? - 为什么工具结果必须带
ToolCallID? - 如果模型一直调用工具不停止,代码靠什么兜底?
- 如果再写一个 Agent,这篇代码里哪些部分会被复制?