agent入门篇(一)

5 阅读10分钟

用 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 的核心体验是这样的:用户在终端输入一句话,模型可能直接回答;也可能静默调用 ReadEditBash 等工具,观察工具执行结果,然后继续推理,直到给出一个最终答案。这个过程模型和用户之间有一个看不见的「循环」在驱动——这就是 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 的完整数据流:

  1. Context 组装messages[] 是唯一的上下文载体,包含 system prompt、用户输入、模型历史输出、工具执行结果。每次进循环前,这个数组是完整的。
  2. 模型推理:LLM 看到完整的对话历史(包括上一轮的工具结果),决定下一步做什么——可能是直接给出最终答案,也可能是输出一个或多个 <tool_call>
  3. 解析与分发:系统用 ParseToolCalls 从模型输出的文本中提取 XML 块,交给 Tool Registry 路由到对应的工具实现。
  4. 并行执行:多个 tool_call 之间无依赖,用 goroutine + sync.WaitGroup 并行执行,各自生成 Result
  5. 结果回灌:所有结果按模板格式化为 <tool_result> XML,作为一条新的 user message appendmessages[] 中。
  6. 循环或终结:更新后的 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 格式不对),错误信息会作为 user message 回灌,模型会自动重试。

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 命令(nlcat),而不是在 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/termvisualLength 把每个 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

源码地址:github.com/naowan824/c…

克隆运行:

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升华的太好了!