用Go手撸 Agent 框架#8:手写一个最小 Agent,先让 loop 跑起来

走到第 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 需要有工具,因为这一步要观察的就是"模型决策---本地执行---结果回填"。

这里放两个工具:

  1. get_weather:查询 mock 天气。
  2. 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 已经能跑。

但它也暴露了几个问题:

  1. 工具定义和工具实现分散在一起。
  2. 工具分发靠 switch
  3. 模型调用直接写死 DeepSeek。
  4. messages 结构直接依赖 OpenAI SDK。
  5. 每个 Agent 都要复制同一段 loop。

当前只有两个工具,switch 还很直接。但如果再写一个 Agent,这份 main.go 里哪些代码需要复制?如果再加几个工具,工具定义和 switch 会变成什么样?

这些是跑完第一版以后才看到的问题。下一步先不猜答案,而是带着这些问题继续整理代码。

这一篇先确认一件事:

最小 Agent 的核心不是工具有多少,而是能否稳定完成"模型决策 -> 本地执行 -> 结果回填 -> 再次决策"的闭环。

最后留几个自测问题:

  1. 模型返回 tool_calls 时,工具是否已经执行?
  2. 为什么 assistant 的 ToolCalls 消息要放回 messages
  3. 为什么工具结果必须带 ToolCallID
  4. 如果模型一直调用工具不停止,代码靠什么兜底?
  5. 如果再写一个 Agent,这篇代码里哪些部分会被复制?
相关推荐
小毕超2 小时前
生产级智能体框架 Pydantic AI 介绍和使用
agent·pydantic·pydanticai
怕浪猫2 小时前
第14章 性能优化与成本控制
openai·agent·ai编程
MobotStone4 小时前
为什么你的智能体总在第二步就崩溃?
agent
KaneLogger17 小时前
防止 AI Agent 误删文件,最简单的办法就是加一个 Hook
llm·agent·trae
永远的沙雕网友18 小时前
写点粗浅的harness相关内容(1)
agent
为你学会写情书19 小时前
LangChain.js RAG 实战:爬取掘金文章,让 LLM 告别幻觉
agent
带刺的坐椅19 小时前
SolonCode v2026.7.13 发布:多工作区协同、推理水平可控、子代理任务分组、工程体验再进化
llm·agent·codex·solon-ai·claudecode·opencode
AI程序员20 小时前
会写代码的 AI 很多,能算 Coding Agent 的没几个
人工智能·agent
zxfeng~21 小时前
伴眸-居家养老AI 眼镜智能体 技术方案
人工智能·agent·ai编程·智能眼镜