用 Go 实现一个 Agentic CLI
目标:深入理解 Agent 的底层运行机制(Query Loop、Tool Calling、Context 管理),并用 Go 标准库从零实现一个简化但可用的交互式命令行 Agent。
一、项目背景:为什么要从零开始
这个项目的起点是昕哥的一次分享。当时我对 Agent 底层的运行机制产生了浓厚兴趣——不是对 Claude Code 这个产品做研究,而是想用纯粹的工程思维去理解:模型到底是如何被「驯服」为一个 Agent 的?一次 Agent Loop 里到底发生了什么?输入和输出是如何被控制的?
现在的社区里,大家一谈到 Agent 就想到 LangGraph、Enio 这些框架。框架当然好用,但如果一上来就套用框架,很容易把框架的封装当成理所当然的黑盒,而忽略了背后的核心原理。事实上,当你真正理解一次 Agent Loop 是怎么跑起来的——模型输出什么、系统怎么解析、工具怎么执行、结果怎么回灌——你会发现框架只是在帮你做那些琐碎的中间步骤而已。
所以我决定从零开始,用 Go 标准库写一个非常精简但可运行的 demo:
- 不依赖任何 LLM SDK(纯
net/http调 DeepSeek API) - 不依赖任何 Agent 框架(自己写 Loop、自己管理 Context)
- 用 Claude Code 来 vibe coding(它本身就是最好的 Agent IDE)
这个项目目前只覆盖了最基础的 Phase 1——Agent Loop、Tool Calling、Context 管理。后续我还计划写 Skills、MCP 的实现,以及 Offloading、Context Compact 等长对话优化理念,有空再整理成文。
本项目所有的 system prompt、tools template、tool result template 等,全部由 Kimi(或千问 / 豆包等)生成。实践证明,模型写的 prompt 往往比人写的更出色——尤其是在需要定义严格的 XML 协议、边界条件和 corner case 处理时,人类很难一次性写清楚这么长的结构化指令。与其反复 hand-tune,不如让模型帮你写 prompt,人只做 review 和微调。
另外,本文中所有夸奖 Claude Code 的语句,都是 Claude 自己在 vibe coding 过程中生成的,不是本人写的。特此声明 😄
二、项目总览:我们在做什么
Claude Code 的核心体验是这样的:用户在终端输入一句话,模型可能直接回答;也可能静默调用 Read、Edit、Bash 等工具,观察工具执行结果,然后继续推理,直到给出一个最终答案。这个过程模型和用户之间有一个看不见的「循环」在驱动——这就是 Agent Loop(智能体循环)。
本项目是一个 Phase 1 级别的实现,目标是把 DeepSeek API(OpenAI-compatible)接入到一个交互式 CLI 中,让模型能够:
- 读取文件(
read_file) - 写入文件(
write_file) - 与用户进行多轮对话(Session / Context 管理)
- 在需要时自发调用工具、观察结果、继续推理
全项目仅用 Go 标准库 + chzyer/readline(终端输入)+ bytedance/gopkg(日志),不依赖任何 LLM SDK。
三、核心概念
2.1 Session & Context:多轮对话的上下文
Session 本质上是 messages[] 数组。每一轮对话都在这个数组上追加新的消息,包括:
| 角色 | 场景 | 示例 |
|---|---|---|
system | 启动时注入一次 | "你是一个有用的 AI 助手..." |
user | 用户的输入 | "帮我看一下 main.go" |
assistant | 模型的回复 | "我来读一下..."(可能包含 <tool_call>) |
user | 工具执行结果回灌 | <tool_result>... |
Context 管理的关键就是维护这个 []Message 切片,确保模型在多轮调用中「记得」之前的所有工具调用结果和推理路径。
💡 设计上没有实现自动压缩或摘要(Context Window 管理是 Phase 2 的内容),但在代码中预留了日志和结构,便于后续扩展。
2.2 Agent Loop:模型与工具的循环交互
Agent Loop 是整个项目的灵魂。它的核心逻辑用一句话概括:
调用模型 → 解析响应 → 若有 tool_call 则执行 → 把结果回灌给模型 → 再次调用模型 → 直到模型不再调用工具。
这是一个「自省(self-reflective)」的循环——模型根据前一轮的工具结果,决定是继续调用工具还是给出最终答案。
Agent Loop 交互图
┌─────────────┐
│ 用户输入 │
└──────┬──────┘
│
▼
┌─────────────────────────────┐
│ messages[] Context │
│ [system, user, assistant, │ │ tool_result, user, ...] │
└─────────────┬───────────────┘
│ ① 把完整上下文发给模型
▼
┌─────────────────────────────┐
│ DeepSeek API (LLM) │
│ 模型基于上下文推理,决定 │
│ 直接回答 或 输出 tool_call │
└─────────────┬───────────────┘
│ ② 返回 response.content
▼
┌─────────────────────────────┐
│ ParseToolCalls(content) │
│ 解析 <tool_call> XML 块 │
└─────────────┬───────────────┘
│
┌─────────────┴─────────────┐
│ │
没有 tool_call 有 tool_call
│ │
▼ ▼
┌─────────────┐ ┌─────────────────────┐
│ 直接返回答案 │ │ Tool Registry 路由 │
│ (本轮结束) │ │ 并行执行所有工具 │
└─────────────┘ └──────────┬──────────┘
│ ③ 执行 bash 命令
│ (nl, cat, ...)
▼
┌─────────────────────┐
│ 收集 stdout/stderr │
│ 构建 <tool_result> │
│ 格式化为 user msg │
└──────────┬──────────┘
│ ④ 回灌到 messages[]
│ (append)
▼
┌─────────────────────┐
│ messages[] 更新后 │
│ 回到步骤 ①,再次调用 │
│ 模型,继续推理... │
└─────────────────────┘
上图展示了 Agent Loop 的完整数据流:
- Context 组装:
messages[]是唯一的上下文载体,包含 system prompt、用户输入、模型历史输出、工具执行结果。每次进循环前,这个数组是完整的。 - 模型推理:LLM 看到完整的对话历史(包括上一轮的工具结果),决定下一步做什么——可能是直接给出最终答案,也可能是输出一个或多个
<tool_call>。 - 解析与分发:系统用
ParseToolCalls从模型输出的文本中提取 XML 块,交给Tool Registry路由到对应的工具实现。 - 并行执行:多个
tool_call之间无依赖,用 goroutine +sync.WaitGroup并行执行,各自生成Result。 - 结果回灌:所有结果按模板格式化为
<tool_result>XML,作为一条新的usermessage append 到messages[]中。 - 循环或终结:更新后的
messages[]再次进入循环(步骤①)。如果模型这次不再输出tool_call,循环结束,返回最终答案。
func agenticLoop(
client *deepseek.Client,
reg *tools.Registry,
cfg *config.Config,
term *ui.Terminal,
messages []deepseek.Message,
turn int,
) (string, []deepseek.Message, error) {
for round := 1; round <= maxToolRounds; round++ {
// 1. 调用 DeepSeek API
resp, err := client.Chat(req)
content := resp.Choices[0].Message.Content
messages = append(messages, deepseek.Message{Role: "assistant", Content: content})
// 2. 解析 <tool_call> XML 块
calls, err := tools.ParseToolCalls(content)
if len(calls) == 0 {
// 没有 tool_call,本轮终结,返回最终答案
return content, messages, nil
}
// 3. 并行执行工具(goroutine + WaitGroup)
results := runToolsParallel(reg, calls)
// 4. 把结果格式化为 <tool_result> 回灌给模型
feedback, _ := buildToolFeedback(cfg.Prompt.ToolResultTemplate, calls, results)
messages = append(messages, deepseek.Message{Role: "user", Content: feedback})
}
// 5. 达到最大轮数上限时,注入 system_reminder 要求模型给出最终结论
}
关键设计决策:
- maxToolRounds = 10:防止模型陷入无限循环。达到上限时注入
system_reminder,强制模型基于已有信息给出最终答案。 - 工具并行执行:多个
tool_call之间无依赖关系,用sync.WaitGroup并行执行。 - 错误自愈:如果
ParseToolCalls解析失败(XML 格式不对),错误信息会作为usermessage 回灌,模型会自动重试。
2.3 Tool 系统:一切操作都是 bash 命令
本项目采用了一个简洁的设计哲学:每个 Tool 本质上是一个「params → bash Command」的翻译器。
type Tool interface {
Name() string // 工具名(与 <tool_call><name> 对应)
BashCommand(params map[string]any) (Command, error) // 把参数翻译为 bash 命令
}
例如 read_file 的实现:
type ReadFile struct{}
func (ReadFile) Name() string { return "read_file" }
func (ReadFile) BashCommand(params map[string]any) (Command, error) {
path, ok := params["path"].(string)
if !ok || path == "" {
return Command{}, fmt.Errorf("read_file: missing or invalid 'path' parameter")
}
// 用 nl -ba 输出带行号的文件内容,空行也参与编号
return Command{
Script: fmt.Sprintf("nl -ba %s", shellQuote(path)),
}, nil
}
另一个有趣的例子是 write_file——它使用 cat > path + stdin 来写入文件,而不是把内容拼进命令行字符串,避免了复杂的引号转义问题:
func (WriteFile) BashCommand(params map[string]any) (Command, error) {
path, _ := params["path"].(string)
content, _ := params["content"].(string)
plain, n, err := stripLineNumbers(content) // 从 nl 格式中剥离行号
quoted := shellQuote(path)
return Command{
Script: fmt.Sprintf("cat > %s && echo wrote %d line(s) to %s", quoted, n, quoted),
Stdin: plain, // content 通过 stdin 传递
}, nil
}
这种设计的优点是:工具实现保持纯函数风格,可测试性强。 复杂的文件读写逻辑被交给成熟的 shell 命令(nl、cat),而不是在 Go 代码里重复实现。
2.4 Tool Call XML 协议
模型与系统之间的工具调用协议使用 XML 格式,而非 Anthropic 原生的 JSON schema。原因是在 Phase 1 使用 DeepSeek API 时,通过 prompt engineering(在 system prompt 中声明 XML 协议) 是最直接的实现方式:
<tool_call>
<name>read_file</name>
<params>{"path": "cmd/main.go"}</params>
</tool_call>
解析器采用「正则提取」而非标准 XML 解析器:
var (
toolCallPattern = regexp.MustCompile(`(?s)<tool_call>(.*?)</tool_call>`)
namePattern = regexp.MustCompile(`(?s)<name>(.*?)</name>`)
paramsPattern = regexp.MustCompile(`(?s)<params>(.*?)</params>`)
)
原因很实际:模型的输出常常夹杂着自然语言和 XML,<params> 内部又是 JSON,标准 XML 解析器很容易因为混合内容而报错。正则的「先抓块、再分别取字段」策略更鲁棒。
四、代码走读:关键模块
3.1 DeepSeek API Client(internal/deepseek/)
完全基于 net/http 的轻量级客户端,支持自定义 base URL:
type Client struct {
httpClient *http.Client
apiKey string
baseURL string
}
func NewClientWithURL(baseURL string) (*Client, error) {
key := os.Getenv("DEEPSEEK_API_KEY")
if key == "" {
return nil, fmt.Errorf("DEEPSEEK_API_KEY environment variable not set")
}
return &Client{
httpClient: &http.Client{Timeout: 60 * time.Second},
apiKey: key,
baseURL: baseURL,
}, nil
}
数据类型保持与 OpenAI API 格式一致:
type ChatRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
Stream bool `json:"stream,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
}
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
}
3.2 配置系统(internal/config/)
所有 prompt 模板、模型参数、工具定义都从 config.yaml 加载:
model:
provider: deepseek
name: deepseek-v4-flash
base_url: https://api.deepseek.com
max_tokens: 2048
temperature: 0.7
prompt:
system: |
You are a helpful AI assistant...
<workspace>
<path>{{.WorkspacePath}}</path>
</workspace>
tools:
- name: read_file
description: Read the content of a file at the given path.
input_param: '{"path": "string"}'
Go 代码中使用 text/template 渲染所有模板(system prompt、tools description、tool_result feedback、system_reminder),这使得整个提示词系统完全可配置,无需改代码就能调整模型行为。
3.3 终端交互(internal/ui/terminal.go)
选用 github.com/chzyer/readline 而非 golang.org/x/term 的关键原因是:CJK 字符宽度处理。
golang.org/x/term 的 visualLength 把每个 rune 计为宽度 1,但中文字符实际占 2 列,导致退格时光标位置错乱。chzyer/readline 内部使用 []rune 并正确处理 rune width,支持箭头键、历史记录、CJK/emoji 删除。
func NewTerminalWithConfig(prompt string) (*Terminal, error) {
rl, err := readline.NewEx(&readline.Config{
Prompt: prompt,
HistoryFile: ".history",
HistoryLimit: 500,
})
}
3.4 启动流程(cmd/main.go)
程序启动时的 9 步流水线(全部有 INFO 级别日志):
Step 1: Load config.yaml
Step 2: Create DeepSeek API client (reads DEEPSEEK_API_KEY)
Step 3: Initialize terminal UI (chzyer/readline)
Step 4: Read current working directory
Step 5: Render system prompt template with workspace path
Step 6: Build conversation history (system + tools description)
Step 7: Initialize tool registry
Step 8: Enter interactive loop
每一轮对话的内部状态变化都会被记录:
[Turn 1] User input: "帮我看一下 main.go"
[Turn 1] Inner round 1, message_count=3
[Turn 1] Round 1 parsed 1 tool_call(s)
[Turn 1] Round 1 tool[0] read_file params={"path": "cmd/main.go"}
use read_file tool
[Turn 1] Round 1 tool[0] read_file ok, output_size=12345
[Turn 1] Round 2 no tool_call, returning final content
五、总结:做了什么,接下来做什么
已经实现的(Phase 1):
- ✅ 基于
net/http的 DeepSeek API 客户端 - ✅ XML 协议驱动的 Tool Calling(read_file / write_file)
- ✅ Agent Loop:模型 → 工具 → 结果回灌 → 模型循环
- ✅ 多轮对话 Context 管理(message[] 数组)
- ✅ 可配置的 prompt 模板系统(Go text/template)
- ✅ 支持 CJK/emoji 的交互式终端(chzyer/readline)
- ✅ 完整的多级日志(INFO / DEBUG)用于问题排查
Phase 2 的方向:
- 接入 Anthropic API(SSE Streaming,原生 Tool Use JSON schema)
- 更丰富的工具集(Edit、Bash、Glob、Grep 等)
- Context Window 压缩(长对话自动摘要)
- 权限检查(write 操作需要用户确认)
- Streaming 输出(实时打印 token,而非等完整响应)
GitHub
克隆运行:
export DEEPSEEK_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxx
export MODEL_NAME=deepseek-v4-flash # 或 deepseek-v4-pro
export BASE_URL=https://api.deepseek.com
cd coding_agent
go run ./cmd/main.go
这个项目不是为了复刻一个完整的 Claude Code,而是想通过亲手实现每一个核心组件,真正理解 Agentic 系统的工作方式——当你亲手写出
agenticLoop的那一刻,你会突然明白为什么模型「能够」调用工具:它不是魔法,只是一段在循环中解析 XML、执行命令、拼接字符串的代码。claude code升华的太好了!