# 第 1 章：编程智能体是什么

在动手写代码之前，我们先搞清楚要构建的东西。

"AI 编程助手"这个词被用烂了，它可以指 GitHub Copilot 的行内补全，也可以指 Cursor 的侧边聊天窗口，也可以指 Claude Code 这样可以自主修改代码、运行测试、提交代码的工具。它们在用的是同一类底层模型，但架构完全不同。

我们要做的是最后这一类：**能自主行动的编程智能体**。

---

## 1.1 不是聊天机器人：工具调用循环的本质

聊天机器人的模型很简单：用户说一句话，模型回一句话，用户再说，模型再回。每一轮都是独立的问答。

编程智能体不是这样的。它的核心是一个**循环**：

```
用户提问
  └─> 发送给模型（含工具列表）
        └─> 模型决定：回答用户，或者调用某个工具
              ├─> 如果是回答：显示给用户，等待下一轮
              └─> 如果是工具调用：
                    执行工具（读文件、运行命令……）
                    把结果发回模型
                    模型继续思考
                    └─> 回到"模型决定"这一步
```

这个循环不是"模型调用工具"，而是"模型在对话历史里声明它想调用工具，然后由我们（宿主程序）去执行，再把结果追加到对话历史里"。模型本身只是一个函数：输入是消息列表，输出是新的消息。工具的执行发生在模型之外。

这个区别很重要，因为它决定了很多设计——比如取消操作的时候要删掉哪些消息，历史损坏的时候能修什么，权限怎么管控。

### Agent 循环的最小骨架

用伪代码写出来，大概是这样：

```go
messages := []Message{systemPrompt, userMessage}

for {
    response, err := llm.Chat(ctx, messages)
    if err != nil { return err }

    messages = append(messages, response.Message)

    if response.FinishReason == "stop" {
        // 模型认为任务完成，跳出循环
        break
    }

    if response.FinishReason == "tool_calls" {
        // 模型想调用工具，逐个执行
        for _, call := range response.ToolCalls {
            result := executeTool(call)
            messages = append(messages, toolResultMessage(call.ID, result))
        }
        // 继续循环，把工具结果发回模型
        continue
    }
}
```

真实的实现要复杂得多——流式输出、错误处理、取消、并发——但这个骨架就是所有编程智能体的核心。

---

## 1.2 逆向分析 Claude Code 的行为

在我们自己构建之前，先观察一下真实的 Claude Code 是怎么工作的。

打开一个 Go 项目，让 Claude Code 修一个 bug：

```
你: 修复 internal/server/server.go 里的 nil pointer panic
```

Claude Code 的输出大概是这样：

```
我来看一下这个文件。
[读取 internal/server/server.go]

发现问题：第 47 行在 s.handler 为 nil 时直接调用了它。让我找一下调用方。
[搜索 NewServer 的调用]
[读取 cmd/main.go]

修复方法是在 NewServer 里加一个默认 handler。
[修改 internal/server/server.go]
[运行 go test ./internal/server/...]

测试通过。问题已修复。
```

从这个过程可以看出几件事：

**模型在主动规划**。它没有等用户告诉它"先读文件，再搜索，再修改"，而是自己决定了行动顺序。这是因为工具的 description 写得足够好，模型知道每个工具做什么，能自行选择。

**工具调用是多轮的**。一次用户消息触发了四次工具调用：读文件、搜索、读另一个文件、修改。每次都要走一个完整的 LLM 推理轮次。

**模型知道什么时候停下来**。运行测试通过之后，它没有继续做其他事情，而是报告完成。这是 `finish_reason == "stop"` 的情形。

**失败是可恢复的**。如果测试失败，它会读测试输出，继续修改，再次测试。Agent 循环会持续，直到它放弃或成功。

### seek 能做到这些吗？

是的，这正是我们要构建的。在本书结束时，你的 seek 会能做到：

- 自主规划：不需要用户指定"先读文件再修改"
- 多轮工具调用：一次请求可以执行任意多次工具
- 流式输出：模型的思考过程实时显示，不需要等待完成
- 会话持久化：关掉终端再打开，上下文还在
- 成本感知：每次请求都知道花了多少 token，多少钱

---

## 1.3 seek 的设计目标与边界

**目标**：一个可以在真实代码库上工作的编程智能体 CLI，行为接近 Claude Code，代码可读、可扩展、可以作为学习材料。

**不是目标**：
- 与 Claude Code 功能完全对等（那需要几十个工程师做几年）
- 生产级的安全性（seek 是单用户本地工具，威胁模型不同）
- 对所有语言、所有代码库开箱即用（我们从 Go 开始，通用性是 M6 之后的事）

**一个重要的边界决策**：seek 使用 DeepSeek 作为主模型，而 Claude Code 使用 Claude。这不是因为哪个更好，而是因为 DeepSeek 有几个对我们的学习目的特别有价值的特性：前缀缓存、reasoner、FIM——它们让我们有机会实现真实的性能优化，而不是把优化留给"未来"。

---

## 1.4 技术选型：Go + DeepSeek

### 为什么是 Go

**单二进制**。`go build` 产出一个可执行文件，没有依赖，可以直接分发。对于命令行工具，这是最重要的特性。

**标准库够用**。seek 的核心部分（HTTP 客户端、JSON 解析、并发原语）全部用标准库实现，`pkg/deepseek` 零外部依赖。这不是洁癖，是架构决策——依赖越少，行为越可预测，在坑里待的时间越短。

**类型系统逼你做决定**。在 Go 里，你必须明确规定"工具拒绝访问时返回 error 还是返回一条消息"。这种强制性在设计阶段很烦，但它能把很多 bug 消灭在编译期。

**并发模型清晰**。Agent 循环、TUI 渲染、流式输出处理——这些是天然并发的。Go 的 goroutine 和 channel 让这三条线互相隔离又能通信，不需要引入复杂的 async/await。

### 为什么是 DeepSeek

**前缀缓存**。DeepSeek 会缓存 prompt 的前缀部分，如果前缀和上一次请求相同，就不重新计算。seek 的 system prompt（工具描述 + 项目说明）通常有 2KB 以上，命中缓存意味着这部分 token 成本打到约 1/50（V4-Flash 实际价格：命中 $0.0028/M，未命中 $0.14/M）。在真实测试中，多轮对话的缓存命中率超过 70%。

**Reasoner**。DeepSeek V4 支持 `thinking` 模式，可以做明确的多步推理。我们会把它包装成一个 `think` 工具：聊天模型遇到复杂问题时，调用 reasoner 做深度分析，把推理结果作为工具输出返回。

**FIM 端点**。Fill-in-the-Middle 是一个专门为代码补全设计的接口：给出光标前的代码和光标后的代码，模型填中间。比 chat 便宜，延迟更低，适合小范围的精确编辑。

**价格**。写作时，DeepSeek V4 的价格约为 GPT-4o 的 1/20，Sonnet 的 1/10，同时能力相当。对于一个学习项目，低成本意味着你可以做更多实验，不需要每次测试都盯着账单。

### 架构上的一个关键约束

seek 的代码库分两层：

- `pkg/deepseek`：DeepSeek 专用客户端，有 DeepSeek 独有的字段（前缀缓存元数据、FIM 端点、reasoner 内容）
- `pkg/llm`：通用 LLM 接口，供第二梯队 provider 使用

**`pkg/deepseek` 不能 import `pkg/llm`**，CI 有 lint 规则强制执行这一点。

原因：如果 deepseek 依赖了通用接口，就意味着 DeepSeek 的独特特性必须抽象成通用接口的一部分，或者被丢弃。两个选择都很糟糕。我们希望 DeepSeek 的每一个优化（缓存元数据、FIM、reasoner）都能在不被"通用化"拖累的情况下充分发挥。

这个设计在 M6 引入多 Provider 时会显得非常正确。到时候你会看到：Anthropic 和 OpenAI 的适配层完全不需要碰 `pkg/deepseek` 的任何一行代码。

---

### 相关踩坑

本章讨论的设计约束在开发中对应了以下具体教训，以下是来自 [`docs/pitfalls.md`](../pitfalls.md) 的详细记录：

**1. 系统提示词中声明的非存在参数被模型反复尝试传入**

- **Saw**：模型在每次调用 `read` 工具时都试图传入 `limit` 参数，但工具 schema 中没有此参数，模型反复收到错误。
- **Fix**：在工具 schema 中添加 `limit` 参数，接受但强制上限为 50。`expected_replacements` 同理。
- **Lesson**：如果模型坚持使用某个参数，即使它不在初始设计中，添加它比反复拒绝更省 token。

**2. 前缀缓存对短提示词不生效**

- **Saw**：首次请求的缓存命中率是 0%，短 prompt 几乎从不命中。
- **Why**：DeepSeek 的前缀缓存要求前缀 ≥ ~64 token，且字节序列必须完全一致。这是全书所有字节稳定性设计的前提。

详见 [`docs/pitfalls.md`](../pitfalls.md) 全文——130+ 条持续更新的踩坑记录。

---

## 本章小结

- 编程智能体的核心是工具调用循环：模型不直接执行动作，而是声明想要执行什么，由宿主程序执行后把结果返回给模型
- seek 的目标是实现这个循环的关键部分，理解每一层的设计权衡
- Go + DeepSeek 的选择有明确的理由，不是随机的
- `pkg/deepseek` 和 `pkg/llm` 的分层是全书最重要的架构决策之一

下一章，我们深入 DeepSeek 的 API 协议：流式输出是怎么工作的，工具调用的 delta 为什么要按 index 合并，前缀缓存需要什么条件才能命中。这些是第 3 章动手写代码之前必须理解的基础。

---

*对应代码：尚未开始。第 3 章会有第一个可运行的版本。*
