[!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:,而是通过结构化的 tools、tool_calls 和 function_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_KEY、DASHSCOPE_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 还是外部接口,注册时只需要提供 Schema 与 Call 两个方法。工具可以有不同的实现方式,例如函数工具、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 的三项职责
- 保存
Tool Schema
- 查找:根据工具名定位
Tool
- 执行:调用对应的
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
这段命令的原理如下是:

本地输出示例:
// 😀😁😂😃😄😅😆😇😈😉😊😋😌😍😎😏😐😑😒😓😔😕😖😗😘😙😚😛😜😝😞😟😠😡😢😣😤😥😦😧😨😩😪😫😬😭😮😯😰😱😲😳😴😵😶😷😸😹😺😻😼😽😾😿🙀🙁🙂🙃🙄🙅🙆🙇🙈🙉🙊🙋🙌🙍🙎🙏