AgentGo自我实现记录:S01-Agent Loop

Polaris_Go 2026-07-31 16:36 1


[!info]- 写在前面



  • 本项目是参考 learn-claude-code 对照实现的 Go 版本。

  • 项目主要使用 Go + OpenAI Chat API 开发。

  • 我是一名刚入行不久的 Go 后端开发,此前主要从事 Python 后端开发,项目学习过程中也使用了较多 AI 辅助。

  • 因此,部分代码在简洁性、规范性及工程设计方面可能仍有不足。希望有 Go 大佬批评指正、分享建议;我也希望逐步去除“AI 味”,学习更规范的 Go 写法(AI 写的 Go 代码总让我感觉有些 Java 味道)。



前言


learn-claude-code 是一个很优秀的开源项目。本系列将参考它的章节设计,使用 Go 对核心能力进行对照实现。第一阶段从最基础的 Agent Loop 开始。


1. Agent Loop 是什么?


1.1 从普通 LLM 调用说起


普通的 LLM 调用通常只有一次返回:输入用户消息、系统提示词和历史消息,输出 LLM 给出的文本答案。


例如,当你向大模型提出“帮我查一下今天天气”时,大模型可以输出一次回复,但回复结束后就会停止。它不会自己继续处理,也不会主动记录外部查询结果。除非你复制它的回复,再补充下一步指令,如此循环往复。


当前要实现的 Agent Loop,就是将这个循环过程自动化。


1.2 Agent Loop 增加了什么?


Agent Loop 在普通 LLM 调用之上增加了一层行动决策:允许 LLM 在单次 Loop 中不直接给出答案,而是选择执行额外操作,例如查询天气、搜索、读写文件或调用业务接口。


程序会将操作结果作为 observation 再交回模型。模型基于 observation 继续推理,既可能继续调用工具,也可能给出最终答案。


这对应经典 ReAct 论文中的 Reasoning + Acting 思路:模型在任务过程中交替进行推理与行动。现代 API 不再需要我们解析纯文本形式的 Action: / Observation:,而是通过结构化的 toolstool_callsfunction_call_output 来表达这一过程。



2. 项目结构与主流程


2.1 当前项目结构



2.2 main 的整体流程



接下来按照数据流转顺序,依次查看环境加载、模型客户端、Tool 抽象、Bash Tool、Schema 转换和主循环的具体实现。


3. 关键模块实现


3.1 环境加载



3.2 模型客户端加载



[!info]- 设计思路

不同厂商使用的 Key 字段及其处理方式并不相同,因此这里针对 OpenAI-compatible 模型的 Client 初始化使用适配器模式。


在当前项目中:



  • Adapter 是业务层期望的 Target:只需要能够 LoadConfig

  • OpenAI、DashScope 的环境变量约定是 Adaptee,例如 OPENAI_API_KEYDASHSCOPE_API_KEY

  • EnvAdapter 是 Adapter:负责将不同的环境变量约定归一化为 Config

  • NewFromEnv / NewClient 是 Client:只消费统一配置,不关心具体厂商。


例如,阿里云使用:


func Aliyun() EnvAdapter {
return NewEnvAdapter(
ProviderAliyun,
"DASHSCOPE_API_KEY",
"DASHSCOPE_BASE_URL",
DefaultAliyunBaseURL,
)
}

外部再调用下面的方法获得最终的 Client:


client, _, err := modelclient.NewFromEnv(modelclient.Aliyun())


详细实现如下。如果有更标准的 Go 写法,也欢迎指出,我会持续改进:


3.3 最小 Tool 结构



[!info]- 设计思路

这部分代码参考了 Go 开源实现。


具体逻辑是以 ToolBox 作为工具能力的单一事实源。任何类型的 Tool,无论是 MCP 还是外部接口,注册时只需要提供 SchemaCall 两个方法。工具可以有不同的实现方式,例如函数工具、MCP 工具、远程工具或测试 Mock。


FunctionTool 是当前项目对函数型 Tool 的封装,也是这套设计中很实用的一层。业务开发者只需要关心:



  • 工具名称

  • 工具描述

  • 参数 Schema

  • 实际处理函数


不需要每次都声明一个新结构体,再手动实现 Schema()Call()


同时,FunctionTool 并没有限制以后添加其他类型:


type MCPTool struct {}
type HTTPTool struct {}
type AgentTool struct {}

只要实现 Tool,它们都能注册进同一个 ToolBox



internal/toolkit/v2/tools.go 只负责三件事:



[!abstract] tools.go 的三项职责



  1. 保存 Tool Schema

  2. 查找:根据工具名定位 Tool

  3. 执行:调用对应的 Tool




type ToolSchema struct {
Name string
Description string
Parameters map[string]any
}

type ToolCall struct {
Name string
Arguments json.RawMessage
}

type Tool interface {
Schema() ToolSchema
Call(
ctx context.Context,
arguments json.RawMessage,
) (string, error)
}

type ToolBox struct {
tools map[string]Tool
}

func NewToolBox(tools ...Tool) *ToolBox {
box := &ToolBox{
tools: make(map[string]Tool),
}

for _, tool := range tools {
box.tools[tool.Schema().Name] = tool
}

return box
}

func (b *ToolBox) Execute(
ctx context.Context,
call ToolCall,
) (string, error) {
tool, ok := b.tools[call.Name]
if !ok {
return "", fmt.Errorf("tool %s not found", call.Name)
}

return tool.Call(ctx, call.Arguments)
}

func (b *ToolBox) Schemas() []ToolSchema {
schemas := make([]ToolSchema, 0, len(b.tools))

for _, tool := range b.tools {
schemas = append(schemas, tool.Schema())
}

return schemas
}

type FunctionTool struct {
schema ToolSchema
fn func(
ctx context.Context,
arguments json.RawMessage,
) (string, error)
}

func NewFunctionTool(
name string,
description string,
parameters map[string]any,
fn func(
ctx context.Context,
arguments json.RawMessage,
) (string, error),
) *FunctionTool {
return &FunctionTool{
schema: ToolSchema{
Name: name,
Description: description,
Parameters: parameters,
},
fn: fn,
}
}

func (f *FunctionTool) Schema() ToolSchema {
return f.schema
}

func (f *FunctionTool) Call(
ctx context.Context,
arguments json.RawMessage,
) (string, error) {
return f.fn(ctx, arguments)
}

3.4 Bash Tool 实现


internal/tools/bash.go 定义了 Bash 工具的参数、Schema 和执行函数:


3.5 OpenAI Tool Schema 转换


ToolBox 使用的是项目内部的 ToolSchema。调用模型前,需要将它转换成 OpenAI SDK 使用的 Tool 参数。


internal/openaiadapter/tool_convert_v2.go




以上模块准备完成后,就可以进入主循环的实现。

## 4. 主循环实现

按照原项目的设计,可以将主流程拆分为两部分:

- `main` 函数负责循环读取用户输入,并维护不同用户轮次之间的消息历史。
- `runAgentLoop` 函数负责调用 LLM、执行 Bash Tool,并在模型给出最终答案后结束当前轮次。

### 4.1 `main`:读取输入并维护会话

```go fold

const modelID = "deepseek-v4-pro"

func main() {
ctx := context.Background()

client, _, err := modelclient.NewFromEnv(
modelclient.Aliyun(),
)
if err != nil {
panic(err)
}

toolbox := v2.NewToolBox(
tools.NewBashToolV2(),
)

chatTools, err :=
openaiadapter.ToChatCompletionToolsV2(
toolbox.Schemas(),
)
if err != nil {
panic(err)
}

system := "你是一个可爱但专业的猫猫娘助手,拥有 Bash 工具能力。能用工具验证就验证,直接回答问题,不解释身份设定,不输出内部思考,不啰嗦。"

messages := []openai.ChatCompletionMessageParamUnion{
openai.SystemMessage(system),
}

scanner := bufio.NewScanner(os.Stdin)

for {
fmt.Print("\033[36m喵喵-go >> \033[0m")

if !scanner.Scan() {
break
}

query := strings.TrimSpace(scanner.Text())
if query == "" ||
strings.EqualFold(query, "q") ||
strings.EqualFold(query, "quit") ||
strings.EqualFold(query, "exit") {
break
}

messages = appendUserMessage(messages, query)

answer, nextMessages, err := runAgentLoop(
ctx,
client,
chatTools,
toolbox,
messages,
20,
)
if err != nil {
fmt.Printf("调用模型失败:%v\n\n", err)
continue
}

messages = nextMessages

fmt.Println(answer)
fmt.Println()
}

if err := scanner.Err(); err != nil {
fmt.Printf("读取输入失败:%v\n", err)
}
}

func appendUserMessage(
messages []openai.ChatCompletionMessageParamUnion,
user string,
) []openai.ChatCompletionMessageParamUnion {
return append(
messages,
openai.UserMessage(user),
)
}

4.2 runAgentLoop:调用模型并执行工具


func runAgentLoop(
ctx context.Context,
client openai.Client,
toolboxSchema []openai.ChatCompletionToolUnionParam,
toolbox *v2.ToolBox,
messages []openai.ChatCompletionMessageParamUnion,
maxSteps int,
) (
string,
[]openai.ChatCompletionMessageParamUnion,
error,
) {
params := openai.ChatCompletionNewParams{
Model: modelID,
Messages: messages,
Tools: toolboxSchema,
}

for step := 0; step < maxSteps; step++ {
completion, err :=
client.Chat.Completions.New(ctx, params)
if err != nil {
return "", messages, err
}

if len(completion.Choices) == 0 {
return "", messages, fmt.Errorf(
"model returned no choices",
)
}

message := completion.Choices[0].Message

messages = append(
messages,
message.ToParam(),
)
params.Messages = messages

// 没有toolcall,说明模型已经给出最终回答。
if len(message.ToolCalls) == 0 {
return message.Content, messages, nil
}

for _, toolCall := range message.ToolCalls {
call := v2.ToolCall{
Name: toolCall.Function.Name,
Arguments: json.RawMessage(
toolCall.Function.Arguments,
),
}

fmt.Printf(
"\033[33m[tool] %s %s\033[0m\n",
call.Name,
string(call.Arguments),
)

result, err := toolbox.Execute(ctx, call)
if err != nil {
result = fmt.Sprintf(
`{"error":%q}`,
err.Error(),
)
}

messages = append(
messages,
openai.ToolMessage(
result,
toolCall.ID,
),
)
}

// 将ToolResult放入下一次模型请求。
params.Messages = messages
}

return "", messages, fmt.Errorf(
"agent loop reached max steps: %d",
maxSteps,
)
}

5. 消息历史与最小上下文记忆


以上代码构成了一个通过控制台输入 User Message、由 LLM 推理后输出结果的最小 CLI Agent Loop。这里值得注意的是,messages 会在 for 循环中持续追加:


messages = appendUserMessage(messages, query)

answer, nextMessages, err := runAgentLoop(
ctx,
client,
chatTools,
toolbox,
messages,
20,
)

messages = nextMessages

当模型调用 Bash Tool 时,runAgentLoop 还会将 Assistant Tool Call 和对应的 Tool Result 一起写入 messages


messages = append(
messages,
message.ToParam(),
)

messages = append(
messages,
openai.ToolMessage(
result,
toolCall.ID,
),
)

这样,CLI 在下一轮就可以看到上一轮发生了什么,这也是“上下文记忆”的最小版本。


它不是长期记忆,也没有压缩、摘要、检索、向量库或 memory policy;它只是将上下文窗口尚未超限时的消息原样保留下来。后续所有与 messages 相关的管理操作,都是在这个基础上进行扩展。


6. 当前能力


当前 CLI 已经可以做到:



  • 持续读取用户输入。

  • 输出模型的返回结果。

  • 保留多轮 messages 上下文。

  • 将 Bash Tool Schema 发送给模型。

  • 接收并执行模型返回的 Bash Tool Call。

  • 将 Bash 执行结果作为 Tool Message 交回模型。

  • 在一次用户请求中循环调用工具,直到模型给出最终答案。


至此,一个最小 Agent Loop 已经完整闭环。下面再将它与 Claude Code 的生产级循环做一次对照。


7. 与 Claude Code 的实现对照


以下内容摘取自原项目 README,并结合 Claude Code 源码进行补充,作为额外的实现对照与知识扩展。



8. 总结


这次实现最重要的收获是:Agent的核心并没有想象中复杂。最小版本就是一个消息循环加一个工具执行器。

然而真正复杂的是后续的工程化问题:



  • 工具如何描述得足够清楚。

  • 工具结果如何安全回填。

  • 命令执行如何被约束。

  • 消息历史如何管理。

  • 错误如何恢复。

  • 用户如何确认高风险动作。


在理解这个最小闭环后,后续的工具扩展、消息管理和安全策略,都可以围绕它逐步增加。


附录:实现过程中的 PowerShell Emoji 小发现


在这个基础实现中,我还发现了一个很有意思的 PowerShell emoji 输出命令:


// PowerShell:
// 0x1F600..0x1F64F | ForEach-Object { [char]::ConvertFromUtf32($_) } | Join-String

这段命令的原理如下是:


本地输出示例:


// 😀😁😂😃😄😅😆😇😈😉😊😋😌😍😎😏😐😑😒😓😔😕😖😗😘😙😚😛😜😝😞😟😠😡😢😣😤😥😦😧😨😩😪😫😬😭😮😯😰😱😲😳😴😵😶😷😸😹😺😻😼😽😾😿🙀🙁🙂🙃🙄🙅🙆🙇🙈🙉🙊🙋🙌🙍🙎🙏
最新回复 (3)
  • Polaris_Go 楼主 07-31 16:49
    1

    在此处督导自己,一定要努力更新 ^-^胡适之啊,这不仅是分享,也是一次知识的回顾,哪怕没有佬来看

  • MrAnti 07-31 17:57
    2

    写的挺好的,示例清晰明了,期待后续更新 ^-^

  • ryan.h_h 08-03 00:24
    3

    感谢分享,这个项目之前看到过,不过一直没有实操。学习一下

* 帖子来源Linux.do
返回