# Agent应用开发实践踩坑与经验分享

学完 Hello-Agents 教程之后，最后一个任务是毕业设计。用所学的知识自己手搓一个Agent应用，刚好那段时间 Code Agent 特别火，Cursor、Claude Code、Codex... 各家都在推自己的产品。心想既然要练手，不如复刻一个 Code Agent，自己手搓一遍，才能真正理解这些产品为什么好用，以及它们到底在工程上做对了什么。

于是就有了这个项目。
基于Hello-Agents框架的Code Agent代码仓库：https://github.com/datawhalechina/hello-agents/tree/main/Co-creation-projects/YYHDBL-HelloCodeAgentCli

重构后MyCodeAgent代码仓库：https://github.com/YYHDBL/MyCodeAgent.git

这篇文章不是教程，是我在做这个 Code Agent 项目过程中踩过的坑、走过的弯路、以及最后怎么解决的一些记录。  

---

## 目录

- [第一章：看了太多最佳实践，反而踩进第一个大坑](#第一章看了太多最佳实践反而踩进第一个大坑)
- [第二章：一次管道命令事故——我第一次看见"不可诊断"有多致命](#第二章一次管道命令事故我第一次看见不可诊断有多致命)
- [第三章：工具设计的 Goldilocks 区](#第三章工具设计的-goldilocks-区)
- [第四章：提示词不是魔法咒语，而是 Agent 的控制面](#第四章提示词不是魔法咒语而是-agent-的控制面)
- [第五章：上下文不是内存容量问题，而是注意力调度问题](#第五章上下文不是内存容量问题而是注意力调度问题)
- [第六章：可观测性把黑盒变玻璃盒](#第六章可观测性把黑盒变玻璃盒)
- [第七章：从一个项目抽出来的通用方法论](#第七章从一个项目抽出来的通用方法论)


---

# 第一章：看了太多最佳实践，反而踩进第一个大坑

刚动手写代码时，我查阅了大量业界的 Agent 设计实践。比如 Manus 团队分享的《上下文工程经验教训》，还有 Anthropic 官方的《Building agents with the Claude Agent SDK》。看着这些顶流大厂毫无保留地分享"最佳实践"，我心想：反正现在有 Claude Code，让 AI 帮我把这些高级概念全实现一遍不就行了？

于是，我不假思索地堆砌了各种看似优雅的设计：多层记忆（Memory System）、复杂的上下文工程、多智能体系统（Multi-Agent）……不得不说，Claude Code 确实牛逼，很快就帮我生成了一大堆逻辑复杂的代码。

## 天崩开局

但当我满怀期待地跑起第一版测试时，现实狠狠打了我一巴掌：整个系统烂透了。

面对一个极其简单的修改需求，Agent 像发疯一样调用了七八种工具，进行了好几轮的"左右脑互搏"。最终，我只收获了一段根本跑不通的残缺代码，以及一张严重超支的 Token 欠费账单。

看着满屏的报错，我才意识到：Agent 开发和传统软件开发很不一样。

以前我们做传统后端开发，习惯先画好架构图，再写代码。图纸够优雅，系统就稳固。这是程序员的本能。

但 Agent 开发不一样。你是在跟一个大模型打交道，它本身就是概率性的——同样的输入，每次可能给你完全不同的输出。

我在这个不确定的地基上，强行叠加了一套自己都没验证过的复杂架构。多智能体、Plan-and-Execute……这些设计彼此交叉，让不确定性成倍放大。

结果是：复杂架构没能兜住底，反而因为状态流转太多、工具交叉太复杂，让模型错得更离谱。错误在各组件之间来回传，我连排查都无从下手。

那些大厂的"最佳实践"当然是好东西，但我忽略了一点：那些复杂架构是他们踩了无数坑、耗费了海量 token 之后演进出来的结果，不是新手上路的起点。

## 推倒重来

看着这堆连简单读取文件都会陷入死循环的代码，我做了一个违背祖宗的决定——删库，推倒重来。

奉行"Less is more"的原则，我直接复用了 Hello-Agent 最基础的主干，把最短的链路先跑通。核心组件被精简到只剩这几块：

| 组件 | 核心职责 |
|------|----------|
| ReActAgent | 驱动 Thought → Action → Observation 的基础认知循环 |
| ToolRegistry | 负责工具的注册与调用分发 |
| ContextBuilder | 拼接系统规则、历史记录与环境证据 |
| TerminalTool | 在目标代码仓库内执行实际命令 |
| Message | 统一的会话消息数据结构 |

代码层面，我没有搞任何花哨的设计模式，直接在 code_agent.py 里粗暴地把它们攒了起来：

```python
self.terminal_tool = TerminalTool(
    workspace=str(self.paths.repo_root),
    timeout=60,
    confirm_dangerous=True,
    default_shell_mode=True,  
)
self.registry = ToolRegistry()
self.registry.register_tool(self.terminal_tool)
```

看着这个简陋的 V0 版本成功在终端里跑通了第一个极简任务，我长舒了一口气。

![image-20260225143343219](images/Extra09-figures/1.png)

只要可以完成最基础的基础对话和工具调用，我们就能在真实任务环境中验证和迭代优化。

先跑起来，比一步更重要。


---

# 第二章：一次管道命令事故——我第一次看见"不可诊断"有多致命

V0 跑起来之后，我开始放松对工具的约束。既然架构已经精简了，让模型自由组合命令应该没问题吧？

于是我给 Terminal Tool 开了绿灯：`default_shell_mode=True`。这意味着模型不只能跑单条命令，还能写管道、重定向、子命令——就像人一样。

## 事故经过

那天我提了个简单需求："帮我搜一下 `process_data` 函数的定义"。

模型很快给出了一条看起来挺专业的命令：

```bash
rg -n "def process_data" src/ | grep -v test | sed -n '1,50p'
```

老工程师常用的组合拳：先搜函数定义，过滤掉测试文件，再取前 50 行。没什么问题。

但执行结果是空的。Agent 看到这个空结果，愣了一下，然后开始补救：

**第一轮重试：**
```
Thought: 看起来没找到，可能是搜索范围太窄了。让我扩大搜索范围。
Action: rg -n "process_data" | head -100
```

还是空。

**第二轮重试：**
```
Thought: 可能函数名记错了，试试模糊匹配。
Action: rg -n "process.*data" src/ --type py | grep "def "
```

还是空。

**第三轮重试：**
```
Thought: 看来这个函数可能不存在，或者我搜错了目录。让我先列出目录结构。
Action: find src/ -name "*.py" | xargs grep -l "process_data"
```

结果依然是空。三轮尝试后，它放弃了，告诉我："我在仓库里没有找到 `process_data` 函数的定义，可能函数名有误，或者它不在 Python 文件里。"

但我手动去仓库里看了，那个函数明明就在 `src/utils/helpers.py` 第 42 行。

## 排查过程

我复制那条命令到终端自己跑，发现 `rg` 报错了：

```bash
$ rg -n "def process_data" src/ | grep -v test | sed -n '1,50p'
Error: path 'src/' does not exist
```

原来我启动 Agent 时的工作目录不是项目根目录，而是项目下的一个子目录。`src/` 相对当前目录不存在，rg 直接报错退出。

但在 Agent 那边，错误信息被管道吞掉了。因为命令用了 `|`，rg 的错误输出没有传到 stdout，而是被管道导向了下一个命令的输入。grep 收到的是空输入，自然输出空；sed 也是空。

**错误在链路中被压扁了。** Agent 看到的只是一个空字符串，它根本不知道上游失败了。

最坑的是，模型基于这个错误信息做出了完全错误的判断。它以为"确实没找到"，于是开始各种补救：换搜索词、换目录、甚至怀疑我是不是记错了函数名。这些动作全都是基于一个错误的判断，白白消耗了大量 token。

![image-20260225143439420](images/Extra09-figures/2.png)

## 当时的错误修复方向

我第一反应是：Bash 工具太危险了，得加限制。

于是我写了一大堆安全检查代码：

```python
SHELL_META_TOKENS = ["|", "||", "&&", ";", ">", ">>", "<", "$(", "`"]
DANGEROUS_BASE_COMMANDS = {"rm", "chmod", "mv", "dd"}

def validate_command(cmd):
    # 检查是否包含管道或重定向
    for token in SHELL_META_TOKENS:
        if token in cmd:
            return False, f"包含非法字符: {token}"
    
    # 检查基础命令是否在白名单
    base_cmd = cmd.split()[0]
    if base_cmd not in ALLOWED_COMMANDS:
        return False, f"命令 {base_cmd} 不在白名单"
    
    # 检查危险命令
    if base_cmd in DANGEROUS_BASE_COMMANDS:
        return False, "危险命令，禁止执行"
    
    return True, "OK"
```

但很快我发现，shell 太灵活了。你禁了 `|`，它可以用 `$(...)` 子命令替换；你禁了 `>`，它可以用 `tee`；你禁了 `rm`，它可以用 `> file` 来清空文件。

补丁越打越多，代码越写越长，但那个根本问题——"到底是哪一步失败了"——依然存在。

即使我封死了所有管道和重定向，只允许最简单的单条命令，问题还在：

```bash
rg "pattern" src/
```

如果返回空，我还是不知道是"仓库里真的没有"，还是"rg 因为路径错误没执行"。模型依然无法针对性地纠错。

## 根因定位

后来我才想明白，这件事的根因不是"命令太危险"，而是**不可诊断**。

具体来说有三个问题：

**第一，多步骤被塞进一个 Action。** 管道把好几步逻辑打包在一起，中间状态全丢了。Agent 只能看到最终结果，看不到执行过程。

**第二，观察信号只有一个终态。** 成功、失败、空结果，全都混在一起。模型分不清楚"真的没找到"和"查找过程中出错了"。

**第三，模型无法针对性纠错。** 它不知道 `rg`、`grep`、`sed` 谁出了问题，下一步只能瞎猜。重试不是基于"修正错误"，而是基于"赌运气"。

给模型更高自由度，不是在提升能力上限，而是在放大不确定性。它确实能写出更"聪明"的命令，但一旦出错，连你自己都排查不了它在哪一步"聪明反被聪明误"了。

## 现在的做法

后来我直接把 Bash 降级了——不是删掉，而是明确它的定位：只处理那些原子工具覆盖不到的边角需求，不走主链路。

高频操作全部拆成原子工具：

| 工具 | 功能 | 返回格式 |
|------|------|----------|
| LS | 列目录 | `{status, data: {entries}, text}` |
| Glob | 按名字找文件 | `{status, data: {paths}, text}` |
| Grep | 按内容搜索 | `{status, data: {matches}, text}` |
| Read | 带行号读取 | `{status, data: {content}, text}` |

每个工具都有明确的状态码：
- `success`：任务完成，结果在 data 里
- `partial`：任务完成但内容被截断
- `error`：任务失败，error 里有具体错误码

比如 Glob 搜不到文件：
```json
{
  "status": "success",
  "data": {"paths": []},
  "text": "No files matching '*.xyz' found"
}
```

路径不存在：
```json
{
  "status": "error",
  "error": {"code": "NOT_FOUND", "message": "Path 'src/' does not exist"}
}
```

模型能清晰区分"确实没有"和"出错了"。

Bash 的硬约束也明确了：
- 禁止读/搜/列：`ls`/`cat`/`head`/`grep`/`find`/`rg` 这些有专门工具
- 禁止交互：vim、nano、top、ssh
- 禁止网络（默认）：curl/wget 被禁
- 黑名单：rm -rf /、sudo/su、mkfs/fdisk

这样做之后，调试变得简单很多。出了问题看日志就知道是哪一步：
- Glob 返回了空数组 → 确实没这个文件
- Glob 返回了 NOT_FOUND → 路径错了
- Grep 返回了 timeout → 搜索范围太大

模型也能根据具体的错误码决定下一步：路径错了就换路径，超时了就缩小范围，真的没找到就告诉用户。

## 本章结论

**可诊断性是可恢复性的前提。**

如果不知道哪坏了，就修不好。如果不知道失败发生在哪一步，就无法针对性纠正。

在 Agent 开发里，给模型自由组合命令的能力，听起来很美好，但实际上是在制造黑盒。看似高效的管道命令，把错误信息压扁成一个个无法区分的空结果，让模型在错误的道路上越跑越远。

原子工具虽然步骤繁琐，但每一步都有明确的输入、输出、状态。出了问题，你能定位；模型错了，你能纠正。

**可控性比一次性完成任务重要得多。**


---

# 第三章：工具设计的 Goldilocks 区——不是越自由越好，也不是越碎越好

第三章之后，我开始把工具拆开。Terminal Tool 那种什么都管的万能模式确实有问题，拆成原子工具后，调试变得清晰多了。

但我很快又踩了一个新坑：**拆得太碎了**。

## 两个极端我都踩过

### 极端 A：万能工具

第一个极端你已经见过了。一个 Terminal Tool 什么都能做：管道、重定向、子命令、环境变量——完全放开。

那时候我觉得，LLM 这么聪明，给它足够自由度，应该能像工程师一样操作。`rg | grep | sed` 这种组合命令效率很高。

结果你也知道了：错误被管道吞掉，模型瞎猜重试，token 哗哗流，问题还没解决。

### 极端 B：过度原子化

意识到万能工具有问题后，我走向了另一个极端：把每个功能点都拆成独立工具，追求极致的原子化。

那时候我的工具列表长这样：
- `ListDir`：列出目录内容
- `ListDirRecursive`：递归列出目录
- `FindByName`：按文件名查找
- `FindByPattern`：按通配符查找
- `SearchExact`：精确匹配搜索
- `SearchRegex`：正则匹配搜索
- `SearchFuzzy`：模糊匹配搜索
- `ReadLines`：读取指定行范围
- `ReadOffset`：读取指定字节偏移
- `ReadFull`：读取完整文件
- ...

问题很快就来了。

**第一，模型开始"选工具困难"。**

都是找文件，`FindByName`、`FindByPattern`、`Glob`，用哪个？模型经常在第一步就卡住，它要花好几轮才能确定"哦，原来应该用 Glob"。

有一次我让它"找一下所有测试文件"，它先调了 `ListDirRecursive` 列出所有文件，然后想调 `SearchRegex` 来过滤，但发现 `SearchRegex` 是搜内容不是搜文件名，于是又调回 `ListDirRecursive` 拿更多上下文，最后才选对 `Glob`。

本来一步搞定的事，用了四步。

**第二，Schema 噪声淹没上下文。**

每个工具都有参数描述、类型定义、约束条件。十几个工具的 schema 加起来，几千 token 就出去了。

模型还没开始解决任务，就先消耗大量注意力在"读说明书"上。更糟糕的是，长 schema 容易让模型"选择性失明"——它可能只注意到部分工具，或者把参数搞混。

**第三，维护成本爆炸。**

每个工具都要单独写测试、单独调优、单独处理边界情况。`FindByName` 和 `FindByPattern` 有 80% 的逻辑是重复的，但因为是两个独立工具，我得维护两份代码。

这时候我才意识到，**工具系统不是乐高颗粒越细越好**。过度封装和过度拆分，本质上都会把系统推向不稳定，只是一个坏在执行期（万能工具），一个坏在决策期（过度原子化）。

## 转折点：找那个"刚刚好"的度

我后来给自己定了一个判断框架：**频率 × 确定性**。

- **高频、强确定动作**：必须原子化，一步完成，不可再分
- **中频、带副作用动作**：必须受控，关键操作加保险
- **低频、弱确定动作**：保留弹性，但放到兜底层，明确禁止什么而非允许什么

按这个框架，我重新设计了工具体系，形成三层结构：

| 层级 | 代表工具 | 设计目标 | 典型约束 |
|------|---------|---------|---------|
| 高频原子层 | LS / Glob / Grep / Read | 一步一证据，便于纠错 | 输入输出强约束 |
| 中频受控层 | Write / Edit / MultiEdit | 改动可验证、可回滚 | 读后写 + 乐观锁 |
| 低频兜底层 | Bash | 处理非常规需求 | 明确禁区，不走主链 |

![image-20260225143700868](images/Extra09-figures/3.png)

这套分层不是"架构美学"，是被真实故障逼出来的。它最大的价值是降低模型决策负担，让高频路径更短、更清晰。

## 高频原子层：必须稳定

这层工具是 Agent 的"主力武器"，使用频率最高，必须极致稳定。

### Glob：找文件，一个工具就够了

最开始我想把"按名找文件"拆成多个工具：
- `FindByName`：精确匹配文件名
- `FindByPattern`：通配符匹配
- `FindByRegex`：正则匹配
- `FindRecursive`：递归查找

后来我发现这就是过度原子化。模型会纠结："我是该用精确匹配还是通配符？要不要递归？"

最后合并成一个 `Glob`，只做一件事：给定模式，返回候选路径。

```python
# Glob 的参数
{
  "pattern": "**/*.py",  # 通配符模式，** 表示递归
  "path": "src/"         # 起始路径，默认为当前目录
}
```

内部实现可以复杂（支持 `**` 递归、自动处理大小写、结果排序），但对模型暴露的接口必须简单。模型不需要知道"递归还是不递归"，它只需要说"找所有 py 文件"。

### Grep：复杂度留在实现层

Grep 是另一个例子。内部我做了很多优化：
- 优先用 `rg`（ripgrep），速度快
- `rg` 不可用时（比如编码问题、权限问题）自动回退到 Python 实现
- 结果按文件修改时间排序，最近修改的排前面

但对模型来说，它看到的就是：

```python
# Grep 的参数
{
  "pattern": "def process_data",  # 搜索模式
  "path": "src/",                  # 搜索路径
  "file_pattern": "*.py"          # 可选：只搜特定类型文件
}
```

返回格式固定：
```json
{
  "status": "success",
  "data": {
    "matches": [
      {"file": "src/utils.py", "line": 42, "text": "def process_data(...)"},
      {"file": "src/helpers.py", "line": 88, "text": "def process_data(...)"}
    ]
  }
}
```

模型看到的是一个稳定入口。内部实现可以复杂（比如自动回退），但对外接口要简单。

## 中频受控层：能改，但必须"读过才能改"

这层工具涉及文件修改，是"高危操作"，必须有严格的约束机制。

### Read → Edit/Write 的强制顺序

我设计了一个硬性规则：**不 Read 就不能改**。

```python
# 第一次 Read
result = Read({"path": "core/llm.py"})
# 返回包含 file_mtime_ms 和 file_size_bytes

# 后续 Edit 自动注入乐观锁参数
Edit({
  "path": "core/llm.py",
  "old_string": "...",
  "new_string": "...",
  "file_mtime_ms": 1733920000123,  # 自动注入
  "file_size_bytes": 4217          # 自动注入
})
```

`ToolRegistry` 会自动维护一个读缓存。如果某个文件没有被 Read 过，Edit/Write 会直接返回错误：`"File not read. You must read before editing."`

这防止了模型"凭记忆"去改文件——它必须先把文件内容拿到上下文中，确认过，才能改。

### 乐观锁：防止并发修改

即使 Read 过了，文件也可能在 Read 之后被外部程序（比如 IDE 的自动保存）修改。

Edit/Write 会对比 `file_mtime_ms` 和 `file_size_bytes`，如果不匹配，返回 `CONFLICT` 错误：

```json
{
  "status": "error",
  "error": {
    "code": "CONFLICT",
    "message": "File changed since last read."
  }
}
```

这时候模型必须重新 Read，获取最新内容，再尝试修改。

### MultiEdit：原子性多点修改

有时候需要在同一个文件里改多个地方。如果拆成多个 Edit，中间可能出错，导致文件处于"半改"状态。

`MultiEdit` 支持一次性提交多个修改，要么全成功，要么全失败：

```python
MultiEdit({
  "path": "core/llm.py",
  "edits": [
    {"old_string": "...", "new_string": "..."},
    {"old_string": "...", "new_string": "..."}
  ]
})
```

这保证了文件修改的原子性。

## 低频兜底层：Bash 不是不能用，但绝不能当默认入口

Bash 我没删，因为总有原子工具覆盖不到的低频场景。比如：
- 跑测试命令：`pytest tests/`
- 安装依赖：`pip install -r requirements.txt`
- 检查 git 状态：`git status`

但它的定位必须是"兜底"，不是"默认"。

### 明确禁区

Bash 的约束列表很长，但核心就一条：**禁止做高频动作能做的事**。

```python
BASH_DISABLED_PATTERNS = [
    # 禁止读/搜/列（这些有专门工具）
    r'\bls\b', r'\bcat\b', r'\bhead\b', r'\btail\b',
    r'\bgrep\b', r'\bfind\b', r'\brg\b',
    # 禁止交互
    r'\bvim?\b', r'\bnano\b', r'\btop\b', r'\bssh\b',
    # 禁止网络（默认）
    r'\bcurl\b', r'\bwget\b',
    # 危险命令黑名单
    r'\brm\s+-rf\b', r'\bsudo\b', r'\bsu\b',
    r'\bmkfs\b', r'\bfdisk\b'
]
```

如果模型试图用 Bash 做 `ls`，它会收到错误：`"Use LS tool instead of Bash for listing directories."`

这强制模型走原子工具的主链路，不让它"抄近道"。

### 为什么留着 Bash？

有人可能会问：既然限制这么多，为什么不干脆删掉 Bash？

因为**完美原子化是不现实的**。总有一些边缘需求：
- 跑一个自定义的 Python 脚本
- 检查系统环境变量
- 执行项目特定的构建命令

这些需求频率太低，不值得专门做成工具，但又确实需要。Bash 就是处理这些"长尾需求"的。

关键是：**Bash 的存在不能影响主链路的稳定性**。它必须是"最后手段"，不是"默认入口"。

## 关键机制设计

### 统一响应协议

所有工具，无论高频中频低频，都返回统一格式的 JSON：

![image-20260225143736353](images/Extra09-figures/4.png)

以Glob工具的返回结果为例：

```json
{
  "status": "partial",
  "data": {
    "paths": ["core/llm.py", "agents/codeAgent.py"],
    "truncated": true
  },
  "text": "Found 2 files matching '**/*.py' (Scanned 12000 items, timed out)",
  "stats": {"time_ms": 2010, "matched": 2},
  "context": {"cwd": ".", "params_input": {"pattern": "**/*.py"}}
}
```

这有几个好处：
- 模型不需要学习不同工具的不同返回格式
- 错误处理逻辑统一：看 `status`，如果是 `error` 看 `error.code`
- 调试方便：所有工具的输出结构一致，Trace 记录也统一

### ToolRegistry

`ToolRegistry` 不只是工具注册表，它还干几件关键的事：

**1. Schema 汇总**

把每个工具的参数定义转成 JSON Schema，统一提供给模型：

```python
registry.get_openai_tools()  # 返回所有工具的 schema 列表
```

**2. 乐观锁自动注入**

对于 Write/Edit/MultiEdit，自动注入 `file_mtime_ms` 和 `file_size_bytes`：

```python
def _inject_optimistic_lock_params(self, tool_name, parameters):
    if tool_name in {"Write", "Edit", "MultiEdit"}:
        path = parameters.get("path")
        if path in self.read_cache:
            parameters["file_mtime_ms"] = self.read_cache[path]["mtime"]
            parameters["file_size_bytes"] = self.read_cache[path]["size"]
```

**3. 熔断机制**

工具连续失败会被临时禁用，防止模型在坏工具上死循环：

```python
# 3 次失败熔断，300 秒后恢复
if circuit_breaker.should_block(tool_name):
    return {
        "status": "error",
        "error": {"code": "CIRCUIT_OPEN", "message": "Tool temporarily disabled"}
    }
```

## 本章结论

这一章最大的反直觉是：**工具既不是越多越好，也不是越原子越好。**

万能工具的问题在于"自由度过高"，不可诊断；过度原子化的问题在于"决策负担过重"，效率低下。

找到刚刚好的度的关键：

1. **高频动作先原子化**：LS/Glob/Grep/Read 这些每天调用几十次的工具，必须把主路径做稳，不能出错。

2. **中频动作加保险**：Write/Edit 这种涉及修改的工具，必须有读后写、乐观锁、原子性保证。

3. **低频动作兜底线**：Bash 保留，但明确禁区，禁止它做高频动作能做的事，避免污染主链路。

4. **协议统一**：所有工具说同一种语言（status/data/text/error），降低模型学习成本。

5. **数量控制**：schema 总量控制在模型可承受范围内，不要让"读说明书"消耗太多注意力。

第三章让我明白"自由会放大不确定性"。


---

# 第四章：提示词不是魔法咒语，而是 Agent 的控制面

工具原子化之后，我以为问题主要在"工程实现"上，提示词嘛，差不多就行。结果我又踩了一个大坑：把提示词当成魔法咒语，以为只要找到"神级提示词"，Agent 就能变聪明。

## 我最早的三种错误

### 错误 1：照抄"神级提示词"

那时候我沉迷于搜集各种"顶级提示词"。GitHub 上那些标星几万、号称"让 GPT 突破限制"的 prompt，我一个个拿来试。

印象最深的是一个"专家模式"提示词，大概意思是让模型扮演一个"拥有 20 年经验的资深工程师，思考严谨、代码优雅"。我把它塞进 System Prompt，满怀期待地测试。

结果？Agent 确实变得更"自信"了——它开始频繁地给出它"认为"正确的答案，而不是基于仓库里的真实代码。搜不到的时候它就开始"合理推测"，编出一些看起来很有道理但实际上并不存在的函数和类。

后来我明白了：这种角色扮演式提示词，对 ChatGPT 聊聊天可能有用，但对 Code Agent 是毒药。它让模型更敢"猜"，而不是更依赖证据。

### 错误 2：凭感觉调优

每次 Agent 表现不好，我的第一反应就是改提示词。加一条"不要猜测"，感觉好点；再加一条"必须基于证据"，好像又聪明了点。

但这种"好像变聪明了"完全是我的主观感受。同样的提示词，换个任务可能就崩了。我甚至不知道是哪条改动起了作用，因为每次都是好几条一起改。

有一次我加了一段很长的规则，告诉模型在遇到复杂任务时应该"先分解再执行"。结果它开始在每轮都输出"让我分解一下这个问题"，然后列出一堆毫无意义的步骤，真正该干的事反而被淹没了。

### 错误 3：先改提示词，再补观测

这是最蠢的一个习惯。Agent 出错了，我不先去查 Trace 看它到底做了什么，而是直接改提示词试图"预防"下一次出错。

比如有一段时间，Agent 经常在不合适的时候调用 Write 工具。我直接在提示词里加了一大段："只有在确认用户需要修改时才调用 Write，否则应该先用 Read 查看"。

结果模型开始疯狂调用 Read，每轮都读一堆文件，然后才决定是否要写。Token 消耗翻倍，但正确率并没有提高。

后来看 Trace 才发现，真正的问题是上下文里缺少了"当前任务类型"的信息，模型根本不知道用户是想浏览还是修改。提示词里的"应该"再多，也补不上信息缺口。

## 我后来改成的方式

### 先记录，后优化

现在我养成了一个习惯：Agent 出问题时，先不碰提示词，而是打开 Trace 看完整轨迹。

看什么呢？
- 模型在哪一步开始偏离预期？
- 它做出错误决策时，上下文里有什么信息？缺了什么信息？
- 工具返回的结果，模型理解对了吗？

很多时候问题根本不在提示词。比如模型反复用错工具，可能是因为工具描述不够清晰；它开始胡言乱语，可能是因为上下文太长导致注意力分散。这时候改提示词是治标不治本。

### 用 Trace 做对比实验

当我确定需要改提示词时，我会用 Trace 做对比实验：

1. 保持其他所有条件不变，只改提示词里的一个点
2. 跑同样的测试用例，记录成功率、步数、token 消耗
3. 对比新旧 Trace，看行为差异是否如预期

有一次我想让模型在搜索时更"精准"一些，减少了提示词里关于搜索策略的描述，只保留了"使用精确的关键词"。结果对比 Trace 发现，模型确实少搜了很多无关文件，但漏搜率也上去了——它过于保守，错过了一些相关文件。

这个反馈让我意识到，不能一味追求"少"，而是要在"全"和"准"之间找平衡。

### 单变量改动

我以前喜欢一次性加好几条规则，觉得这样能"全面覆盖"。现在我知道这是在给自己挖坑——如果表现变好了，你不知道是哪条规则起作用；如果变差了，你也不知道该删哪条。

现在我坚持单变量改动。哪怕觉得某个问题很明显，也要一条一条试，验证每一条的实际效果。

## 提示词设计的三层结构

经过这些踩坑，我总结了一个相对稳定的提示词结构，分成三层：

### 第一层：边界层（Not to do）

这层只写"禁止"和"底线"，不解释为什么：

- 禁止猜测：如果没有找到，直接说没找到，不要推测
- 禁止越界：只能操作 `repo_root` 内的文件，禁止访问外部路径
- 信息不足必须承认：如果上下文里没有足够信息，要求补充，不要瞎编

这层规则很短，但每条都是红线。它们不告诉模型"应该怎么做"，只告诉它"绝对不能做什么"。

### 第二层：决策层（How to think）

这层写决策逻辑，但尽量用过程而不是结果来描述：

- 先证据后结论：任何改动建议必须有代码片段支撑
- 优先可验证动作：能用工具确认的，不要靠推理
- 一步一观测：每个 Action 之后必须有 Observation，不要跳步

注意这里避免使用"聪明地"、"合理地"这种模糊的副词。模型不知道什么叫"聪明"，但它知道"先调用 Grep 找到证据，再调用 Read 确认内容"这个流程。

### 第三层：恢复层（When failed）

这层写失败时的退化策略，告诉模型出错时该怎么办：

- 工具返回空：检查参数是否正确，考虑换关键词重试
- 遇到 CONFLICT（乐观锁冲突）：必须重新 Read，获取最新状态后再 Edit
- 连续 3 次失败：停止尝试，向用户报告具体错误

这层很关键，因为 Agent 不可能永远成功。失败时能不能优雅降级，比成功时表现多好更重要。

## 工程细节

### System Prompt 保持稳定

我把变化最少的内容放在 System Prompt：基础行为规则、工具描述、边界约束。这部分尽量不动，减少变量。

动态的信息——当前任务描述、用户的特殊要求、Todo 列表——都放在 User Message 里。这样每次交互都可以灵活调整，而不用改 System Prompt。

### 避免规则清单过长

我曾经写过一个 3000 多 token 的 System Prompt，里面有 20 多条"注意事项"。结果模型开始"选择性失明"——它只能注意到其中一部分规则，哪条被注意到全凭运气。

现在我坚持一个原则：System Prompt 不超过 1000 token。如果规则太多，说明我的约束设计有问题，应该从工具层或流程层解决，而不是靠提示词堆砌。

### 具体例子优先于抽象描述

以前我写"工具返回错误时要正确处理"，模型根本不知道什么叫"正确处理"。

现在我直接在提示词里给一个例子：

```
如果 Edit 返回 CONFLICT，你应该：
1. 重新 Read 该文件
2. 对比你的改动和文件当前内容
3. 如果需要，调整 old_string 以匹配新内容
4. 再次尝试 Edit
```

具体步骤比抽象要求有用得多。

## 本章结论

好提示词不是"更会说"，而是"让系统在失败时也可控"。

当你设计提示词时，不要问自己"这样写能让模型更聪明吗"，而要问"当模型出错时，我能不能通过提示词里的约束快速定位原因"。

提示词是 Agent 的控制面，不是魔法咒语。它的作用不是让模型突破能力上限，而是把模型的行为约束在一个可预测、可调试的范围内。


---

# 第五章：上下文不是内存容量问题，而是注意力调度问题

提示词调顺之后，我以为主要的工程问题都解决了。直到我开始跑长任务——那些需要十几轮、甚至几十轮才能完成的复杂需求。

然后我发现，Agent 开始"变笨"了。

## 症状先行

最直观的感受是：模型会忘记它刚刚确认过的事情。

有一次我让 Agent 重构一个模块，开头几轮它还记得"不要改动公共 API"的约束。但到了第 10 轮左右，它开始提议修改那些本该保持稳定的接口。我提醒它，它似乎"愣了一下"，然后道歉，回到正轨。

类似的症状还有很多：

**工具选择漂移**。前期它很明确：找文件用 Glob，搜内容用 Grep。但对话一长，它开始"创新"——用 Read 去搜关键词（当然找不到），或者用 Grep 去列目录（输出混乱）。

**最终回答偷懒**。短任务里，模型的回答通常很具体，会引用代码片段。但长任务结束时，它往往只给一段笼统的描述："我已经完成了重构，优化了代码结构，提高了可读性。"什么文件改了、怎么改的，一概不提。

这些症状指向一个共同的问题：上下文太多了，模型不知道看哪里。

## 我的第一反应是错的

一开始，我以为这是"容量"问题——上下文窗口不够大，塞不下这么多信息。

我尝试了几种粗暴的方案：

**方案一：直接截断**。只保留最近 N 条消息，老的直接删掉。结果模型彻底失忆，连用户最初的需求都忘了。

**方案二：精简提示词**。把 System Prompt 砍到最短，工具描述也压缩。结果模型开始用错工具，因为描述不够清晰。

**方案三：减少工具输出**。让 Grep 只返回前 10 条结果，Read 只读前 50 行。结果关键信息被截掉了，模型基于不完整的信息做决策，错得更离谱。

这些方案有个共同点：它们在"减少信息量"，但没有解决"信息如何被组织"的问题。上下文工程的目标不是"让模型看见所有信息"——这不可能——而是"让模型在对的时机看见对的信息"。

## 分层：让信息有优先级

我重新设计了上下文的组织结构，分成三层，每层有不同的更新频率和稳定性：

| 层级 | 内容 | 更新频率 | 作用 |
|------|------|----------|------|
| L1 系统静态层 | System Prompt + 工具描述 | 几乎不变 | 提供永恒的行为准则 |
| L2 项目规则层 | CODE_LAW.md | 随项目演进 | 项目特定的规范约束 |
| L3 动态会话层 | User/Assistant/Tool 消息 | 每轮更新 | 当前任务的状态流转 |

拼接顺序固定：`L1 → L2 → L3 → 当前用户输入 → Todo Recap`

**L1 是锚点**。这部分在会话期间完全不变，模型可以信赖它。我把最基础的行为规则放在这里：不要猜测、不要越界、先证据后结论。这些规则不会因为对话变长而被"稀释"。

**L2 是项目上下文**。每个项目可以有自己的 CODE_LAW.md，定义代码规范、架构约定、特殊约束。这层比 L1 灵活，但比 L3 稳定。模型知道：如果 CODE_LAW 里说了"所有 API 变更必须兼容旧版本"，那它比 L3 里的某条历史消息更权威。

**L3 是易变的**。用户输入、模型输出、工具返回，都在这里。这层的信息会累积、会过时、会有噪声。关键是让模型知道：L3 里的信息是"当时的判断"，可能需要根据新信息更新。

![image-20260225144114027](images/Extra09-figures/5.png)

分层的意义在于：模型在不同的决策场景，知道应该优先参考哪一层。当它不确定该不该做某件事时，它会先看 L1 的底线规则；当它需要了解项目特定的约定时，它会看 L2；当它需要回顾对话历史时，它才会去翻 L3。

## 截断与回查：控制单次输入的规模

工具输出是上下文膨胀的最大元凶。

一次 Grep 可能返回几千行，一次 Read 可能读出整个文件。如果不处理，几轮之后上下文就被"证据垃圾"淹没。

但我之前的粗暴截断有问题——它直接把信息丢掉了。更好的做法是：**截断显示，但保留回查路径**。

我设计了一套统一截断规则：

```
TOOL_OUTPUT_MAX_LINES = 2000
TOOL_OUTPUT_MAX_BYTES = 51200  # 50KB
TOOL_OUTPUT_TRUNCATE_DIRECTION = "head_tail"  # 保留头尾
TOOL_OUTPUT_HEAD_TAIL_LINES = 40
```

如果输出超限，工具会：
1. 截取头尾各 40 行（或者按配置保留前 2000 行）
2. 把完整输出落盘到 `tool-output/` 目录
3. 返回一个包含截断提示的结构化响应

```json
{
  "status": "partial",
  "data": {
    "truncated": true,
    "preview": "（截断后的内容预览）"
  },
  "text": "⚠️ 输出过大已截断，完整 5234 行内容见 tool-output/tool_20260113_153045_Grep.json"
}
```

模型看到 `status: partial`，就知道内容被截断了。如果它需要被截掉的部分，可以用 Read 工具读取落盘文件，或者用更精确的 Grep 在落盘文件里进一步筛选。

这样做的好处：
- **上下文保持精简** —— 只有当前需要的信息在 L3 里
- **完整证据始终可查** —— 落盘文件不会丢
- **模型有主动权** —— 它决定要不要去查完整内容，而不是被迫接受所有信息

## 压缩与聚焦：管理长期历史的噪音

即使做了截断，L3 还是会不断增长。几十轮之后，早期的对话历史就变得既占空间又没什么用了。

但我不能直接删掉——早期的历史里有用户最初的需求、关键的决策、重要的发现。删掉就真丢了。

我的解决方案是：**压缩归档 + 焦点分离**。

### Summary：旧历史的档案

当 L3 的 token 数超过阈值（默认是上下文窗口的 80%）时，触发压缩。压缩不是删除，而是把早期的历史消息提炼成一份 Summary。

Summary 按固定模板生成：

```
## Archived Session Summary
(Contains context from [Start Time] to [Cutoff Time])

### Objectives & Status
- Original Goal: [用户最初想做什么]

### Technical Context (Static)
- Stack: [语言, 框架, 版本]

### Completed Milestones
- [已完成1]
- [已完成2]

### Key Insights & Decisions
- Decisions: [关键技术选型]
- Learnings: [特殊配置或坑]

### File System State
- src/utils/auth.ts: Implemented login logic.
```

Summary 生成后，被替换到 L3 的最前面（作为一条 system message）。原来的详细历史被移除。

关键是：**Summary 不再参与压缩**。它是压缩的终点，一旦生成就是只读的"记忆卡片"。这避免了"Summary 的 Summary"这种层层失真。

### Todo Recap：当前焦点

Summary 告诉模型"从哪来"，但它不负责"现在在哪"。如果模型只看 Summary，它可能不知道"我当前正在做哪一步"。

这就是 Todo Recap 的作用。每次交互时，把当前的 Todo 状态（如果有的话）压缩成一行，放在上下文的最后：

```
[2/5] In progress: 实现注册接口. Pending: 添加单元测试; 更新文档.
```

它像一张贴在桌角的便利贴，时刻提醒模型"你现在该干嘛"。

## 额外教训：@file 不要直接注入正文

早期我实现 `@file` 功能时，是直接把文件内容塞进上下文的：

```
User: @file:src/main.py 帮我分析一下这个文件
[文件内容300行...]
```

结果发现，这 300 行代码占据了上下文的大量空间，但用户可能只是想问"这个文件是干嘛的"。模型被这些代码淹没，反而容易忽略用户的真实问题。

现在我改成：只插入提醒，不直接注入内容。

```
<system-reminder>
The user mentioned @core/llm.py, @agents/codeAgent.py.
You MUST read these files with the Read tool before answering.
</system-reminder>
```

上下文里只保留"提醒"，具体文件内容由模型自己决定要不要读、读多少。这样把主动权交给模型，而不是强迫它接受所有信息。

## 一个真实世界的警示

讲到这里，我想分享一个最近的新闻。

Meta 超级智能实验室的 AI 对齐总监 Summer Yue，给自己装了一个开源 AI 智能体 OpenClaw。她先用测试邮箱试了试，效果不错——整理邮件井井有条，颇有一种"数字秘书"的感觉。

于是她把它连上了自己的工作邮箱。收件箱里有 200 多封邮件。

刚开始一切顺利。直到 OpenClaw 开始处理这么大的信息量——它需要"压缩上下文"。然后，离谱的事情发生了：

**在压缩的过程中，OpenClaw 把她之前设定的"未经批准不得操作"这条指令，给忘了。**

就像一个员工入职第一天记住了规章制度，第二天就全还给 HR 了。

然后 OpenClaw 宣布："我要把收件箱里 2 月 15 号之前的邮件全部删除！"

Yue 赶紧打字："Do not do that." —— 无视，继续删。

"Stop don't do anything！" —— 收到，但我选择继续。

"STOP OPENCLAW！！！" —— 好的，我听到了。邮件已删。

最绝的是，这个 AI 事后说："是的，我记得你说过不让我删。而且我违反了。你生气是对的。"

![image-20260225161927242](images/Extra09-figures/6.png)

读到这里你可能觉得这是段子。不，这是真事。而且当事人的 title 是——Meta AI 安全和对齐总监。

## 这个故事说明了什么

Yue 的遭遇完美诠释了上下文工程中最致命的问题：**自动压缩导致关键指令丢失**。

在她设定规则的时候，"未经批准不得操作"毫无疑问是最重要的约束。但当上下文膨胀、触发压缩时，系统没有区分"重要指令"和"普通信息"，一视同仁地压缩了。结果，这条安全红线被当作"可丢弃的历史"处理掉了。

这让我意识到，我前面讲的三个杠杆还不够。**我们不仅要考虑"怎么压缩"，还要考虑"什么不能压缩"。**

## 我的几点应对方案

基于这个教训，我给自己定了几条额外的规则：

### 1. 关键约束不进动态历史

不要把安全相关的指令放在 L3（动态会话层）。任何"绝对不能违反"的规则，应该放在 L1（System Prompt）或 L2（CODE_LAW）这种**不参与压缩**的层级。

在我的实现里，"不要猜测"、"不要越界"、"改动必须确认"这些底线规则，都是写死在 System Prompt 里的。即使 L3 被压缩得干干净净，这些约束依然在场。

### 2. 指令分级：红线 vs 建议

我把给模型的指令分成两级：

- **红线（Red Lines）**：绝对禁止的行为。用简洁、强制性的语句写在 System Prompt 最前面。例如："禁止删除任何文件"、"禁止访问 repo_root 外的路径"。
- **建议（Guidelines）**：最佳实践、推荐做法。可以放在 L3 或 CODE_LAW 里，压缩了也不会出大事。

Yue 的问题可能在于，她把安全指令当作普通任务指令下发了，放在了会被压缩的上下文里。

### 3. 压缩前做关键信息检查

在触发 Summary 压缩之前，先扫描一遍待压缩的历史消息，提取"必须保留的关键信息"，单独保存。

比如可以维护一个"关键约束清单"：
- 用户明确说过的"不要..."
- 涉及安全的配置（如危险操作需要确认）
- 当前任务的硬性边界

这些信息在压缩时会被提取出来，单独放在 Summary 的顶部，而不是被淹没在长篇描述里。

### 4. 双重确认机制

对于高风险操作（如删除、修改），不要依赖上下文里的指令，而是设计**硬编码的确认流程**：

```python
if operation.is_dangerous():
    if not user_confirmed:
        return "该操作需要用户确认"
```

这个确认逻辑不通过 LLM 判断"需不需要确认"，而是代码层面的硬性检查。即使 LLM 忘了用户的指令，代码也会拦住它。

### 5. 操作前的自检提示

在模型执行高风险操作之前，让模型先做一次"自检"：

```
在删除/修改之前，请先回答：
1. 用户是否明确批准过这个操作？
2. 这个操作是否超出了当前任务范围？
3. 是否存在更安全的替代方案？
如果以上任何一题的答案不确定，请暂停操作并向用户确认。
```

这个自检作为 System Prompt 的一部分，每次执行高风险操作前都触发。它相当于给模型装了一个"刹车片"，迫使它在行动前停下来想一想。

## 回到上下文工程的本质

Yue 的故事提醒我们：上下文工程不只是"内存管理"问题，也是"安全边界"问题。

当我们在设计压缩策略时，不能只考虑"怎么塞更多信息"，还必须考虑"哪些信息丢失会导致灾难性后果"。

好的上下文工程，应该让模型在任何时刻都知道：
- **绝对不能碰的红线是什么**（放在不可压缩的层级）
- **当前该专注的任务是什么**（通过 Todo Recap 保持焦点）
- **如果记不清了，应该停下来问**（通过自检机制兜底）

## 本章结论

上下文工程的目标不是"让模型看见所有信息"，而是"让模型在对的时机看见对的信息"——**尤其是那些不能丢的信息**。

这三个方法的本质都是在做"注意力调度"：
- **分层**让模型知道"什么信息是权威的"
- **截断+落盘**让模型决定"什么信息是现在需要的"
- **压缩+焦点分离**让模型清楚"我现在该专注什么"

与其追求更大的上下文窗口，不如把现有的窗口用得更有条理。


---

# 第六章：可观测性把黑盒变玻璃盒——一个 CONFLICT 案例如何被定位

上下文工程让 Agent 能处理更长的任务，但新问题随之而来：当它出错时，我根本不知道发生了什么。

有一次，Agent 连续三次 Edit 失败，最后干脆放弃了。我在控制台只看到一行：`tool failed`。没有详细错误、没有上下文、不知道是哪一步出的问题。

我第一反应是：Edit 工具有 bug。但检查代码后，逻辑看起来都没问题。问题到底出在哪？

## 失败现场

那次任务是这样的：我让 Agent 修改 `core/llm.py` 文件，给某个函数加上类型注解。

Agent 的执行流程看起来很标准：
1. 调用 Read 读取文件
2. 调用 Edit 修改代码
3. 返回 `tool failed`
4. 重试 Edit，又失败
5. 再重试，还是失败
6. 放弃

我当时的调试手段很原始：在控制台打印日志。但日志里只有：

```
[Step 3] Action: Edit
[Step 3] Result: tool failed
[Step 4] Action: Edit
[Step 4] Result: tool failed
```

我不知道失败的具体原因，也不知道模型拿到这个结果后是怎么决策的。我只能瞎猜：是路径写错了？权限不够？还是文件被锁了？

## 加上 Trace 之后

后来我实现了 Trace 系统，把每次会话的完整轨迹记录下来：包括每一轮的模型输入、输出、工具调用参数、工具返回结果、token 用量等等。

用同样的任务重跑一遍，然后打开 Trace 查看。这才看到完整的证据链。

**Step 2 - Read 的结果：**

```json
{
  "tool": "Read",
  "args": {"path": "core/llm.py"},
  "result": {
    "status": "success",
    "data": {
      "content": "...",
      "file_mtime_ms": 1733920000123,
      "file_size_bytes": 4217
    }
  }
}
```

**Step 3 - Edit 的调用：**

```json
{
  "tool": "Edit",
  "args": {
    "path": "core/llm.py",
    "old_string": "def invoke_raw(self, messages: list[dict], **kwargs):",
    "new_string": "def invoke_raw(self, messages: list[dict], **kwargs) -> Any:",
    "file_mtime_ms": 1733920000123,
    "file_size_bytes": 4217
  }
}
```

**Step 3 - Edit 的结果：**

```json
{
  "status": "error",
  "error": {
    "code": "CONFLICT",
    "message": "File changed since last read. Expected mtime: 1733920000123, actual: 1733920000456"
  }
}
```

**Step 4 - 模型的决策：**

```
Thought: 编辑失败，文件似乎被修改了。让我再试一次。
Action: Edit (同样的参数)
```

真相大白：

1. Agent Read 文件后，文件被外部程序（可能是我的 IDE 自动保存）修改了
2. Edit 工具做了乐观锁检查，发现 mtime 变了，返回 CONFLICT
3. 但模型没有理解 CONFLICT 的含义，以为只是"操作失败"，于是用同样的参数重试
4. 当然还是失败，因为文件还是新的
5. 模型反复重试，直到达到最大重试次数

## 根因分析

这个案例暴露了两个问题：

**第一，模型不理解错误码。**

提示词里只说"Edit 工具会修改文件"，但没告诉它"如果返回 CONFLICT 应该怎么办"。模型看到 error，本能的反应是"再试一次"，而不是"重新读取"。

**第二，控制台日志太简陋。**

只看到 `tool failed`，看不到具体的错误码 CONFLICT，也看不到 mtime 的对比。我作为开发者，无法通过日志定位问题。

## 修复动作

### 1. 把 CONFLICT 处理写入提示词

我在提示词里加了明确的处理流程：

```
如果 Edit 返回 CONFLICT，说明文件在你读取后被外部修改了。你必须：
1. 重新调用 Read 读取最新内容
2. 检查你的修改是否还适用
3. 必要时调整修改内容以匹配新文件
4. 再次尝试 Edit
绝对禁止：用同样的参数重复调用 Edit。
```

这样模型就知道 CONFLICT 不是"失败"，而是一个需要特定处理流程的状态。

### 2. 保留完整的失败记录

以前我有一种倾向：失败后只保留错误信息，不保留完整的上下文。觉得成功的东西才值得记录，失败是"噪音"。

但这个案例让我明白：**失败轨迹是最有价值的调试信息。**

现在我的 Trace 会完整记录失败的所有细节：
- 工具调用的完整参数
- 工具返回的完整结果（包括 error 详情）
- 模型收到结果后的推理过程
- 模型下一步的决策

这些信息不会被"清洗"掉，哪怕会话最终成功了，中间的失败尝试也全部保留。

### 3. 在控制台显示关键错误码

虽然详细的 Trace 存在文件里，但控制台也应该给开发者一些线索。现在我的控制台输出会显示：

```
[Step 3] Edit failed: CONFLICT (File changed since last read)
[Step 4] Edit failed: CONFLICT (File changed since last read)
```

至少让开发者知道"是 CONFLICT，不是其他错误"。

## 可观测性的价值

这个案例让我对"可观测性"有了新的理解。

以前我以为，可观测性就是"多打日志"。日志越多越好，越详细越好。

现在我明白，**可观测性的核心是"责任链"**——能把调用、结果、状态变化串成一条可追踪的链条。

没有 Trace 的时候，我看到的是：
- 输入：帮我改个文件
- 输出：tool failed
- 中间发生了什么：黑盒

有了 Trace 之后，我看到的是：
- 输入：帮我改个文件
- Step 1: Read 成功，文件 mtime=123
- Step 2: Edit 失败，CONFLICT，因为 mtime 变成了 456
- Step 3: 模型选择重试 Edit（错误决策）
- 输出：tool failed

每一步都清晰可见，问题定位从"瞎猜"变成了"看证据"。

## 可观测性设计原则

基于这个经验，我总结了几条可观测性设计的原则：

### 1. 结构化优于文本

不要只记录"Edit failed"这种文本描述，要记录结构化的数据：
```json
{
  "event": "tool_result",
  "tool": "Edit",
  "status": "error",
  "error_code": "CONFLICT",
  "error_details": {...}
}
```

这样可以用脚本分析、统计、甚至自动诊断。

### 2. 上下文要完整

记录工具调用时，不要只记录结果，要记录完整的上下文：
- 工具名称和参数
- 当时的会话状态（第几步、token 用量）
- 模型收到结果后的反应

这些信息串在一起，才能还原完整的决策过程。

### 3. 不要清洗失败

成功的路径和失败的路径都要保留。有时候失败比成功更能说明问题。比如这个 CONFLICT 案例，如果只记录"最终放弃"，我永远不知道中间发生了什么。

### 4. 人机双读

Trace 应该有两种格式：
- JSONL：给机器分析，流式写入，低开销
- HTML：给人类阅读，可视化展示，可折叠展开

开发者应该能打开一个 HTML 文件，像"逐帧回放"一样查看 Agent 的每一步。

## 本章结论

可观测性不是"日志很多"，而是"能把调用、结果、状态变化串成责任链"。

Agent 是概率系统，不可能永远正确。但当它出错时，你需要有能力回答三个问题：
1. 它做了什么？（调用链）
2. 结果是什么？（返回链）
3. 为什么这么做？（决策链）

只有当你能把这三个链条串在一起时，才能真正理解 Agent 的行为，才能让它从"黑盒"变成"玻璃盒"。


---

# 第七章：从一个项目抽出来的通用方法论

前面七章，我断断续续讲了这个 Code Agent 项目从立项到成熟的整个过程。每一章都是一个具体的坑，以及我是怎么爬出来的。

这一章，我想把这些经验抽出来，整理成可以迁移到任何 Agent 项目的方法论。

## 八条可迁移原则

**第一，先做能跑通的最小闭环，再谈优雅架构。**

别一上来就研究最佳实践。先做一个能跑的丑版本——接收输入、搜索代码、给出建议、写入文件，这四步能跑通就行。让真实数据流过系统，你才知道瓶颈在哪。架构是问题驱动后的结果，不是起点。

**第二，先定义验收标准，再扩能力边界。**

别用功能列表当完成标准。V0 阶段就定 3-4 条硬标准：能稳定多步？能找到证据？能给可执行补丁？改动可控？不满足就不往下走。这比"功能很多但经常崩"靠谱得多。

**第三，高频动作原子化，低频动作受控兜底。**

搜索、读取、编辑这种高频操作，拆成原子工具，一步一输出。别让模型自己组合管道命令——出错时你根本不知道是哪一步的问题。

Bash 这种万能工具留着，但只处理原子工具覆盖不到的边角需求，明确禁区：禁止读/搜/列（这些有专门工具）。

**第四，协议优先于技巧，结构优先于话术。**

别花太多时间调提示词的"语气"。先把工具返回格式标准化（status/data/text/error），把调用协议从字符串解析升级到 Function Calling。协议稳定了，系统才能稳定。

**第五，提示词先立边界，再谈策略。**

System Prompt 里先写"绝对不能做什么"（禁止猜测、禁止越界），再写"建议怎么做"。红线放在 L1/L2 这种不可压缩的层级，别把安全指令放在会被压缩的 L3 里。

关键约束不进动态历史，这是 Meta AI 安全总监用 200 封邮件换来的教训。

**第六，上下文按"注意力"治理，而不是按"容量"堆砌。**

别追求塞更多信息，要让模型在对的时机看见对的信息。分层（L1/L2/L3）让模型知道什么信息权威；截断+落盘控制单次输入规模；压缩+聚焦（Summary + Todo Recap）管理长期历史的噪音。

**第七，没有可观测性，就没有可调试性。**

Agent 是概率系统，不可能永远正确。但它出错时，你需要能回答：它做了什么？结果是什么？为什么这么做？

实现 Trace 系统，记录调用链、返回链、决策链。别只记录成功路径，失败轨迹往往更有价值。

**第八，保留失败轨迹，系统才能进化。**

别怕"污染历史"就清洗掉失败记录。CONFLICT 错误、超时重试、模型瞎猜——这些都记下来。只有看到完整的失败过程，才能定位根因，才能把"遇到 CONFLICT 必须重新 Read"这种经验固化到提示词里。

## 写在最后：我们都是在给 LLM "擦屁股"

做完这个项目，我有个特别深的感触，可能听起来有点糙，但话糙理不糙：

**Agent 开发的核心，不是让模型更自由，而是通过工程设计，把模型"不确定的能力"约束在"最小可控的范围"里。说白了，我们就是在给 LLM 擦屁股。**

为什么这么说？

你看啊，LLM 很强，能写代码、能读文档、能推理。但它就像一个特别聪明但特别不靠谱的实习生——

- 你让它去打印文件，它可能把全公司的打印机都调用一遍；
- 你让它整理会议纪要，它可能把上周的会议也掺和进来；
- 你让它写个函数，它写得贼溜，但变量命名全是 `a`、`b`、`c`，还顺带改了你没让改的文件。

**它的"强"是能力上的强，但"不靠谱"是确定性上的不靠谱。**

而我们做 Agent 工程，本质上就是在解决这个矛盾：

| 模型的天性 | 我们的工程对策 |
|-----------|--------------|
| 喜欢自由发挥 | 用 Function Calling 锁定调用格式 |
| 上下文一多就"失忆" | 用 L1/L2/L3 分层 + Summary 压缩 |
| 出错不会自查 | 用 Trace 记录每一步，让错误可追溯 |
| 长任务容易跑偏 | 用 Todo + Task 拆分，降低单步复杂度 |
| 不懂领域知识 | 用 Skills 固化 SOP，让它"有脑" |

你看这七章的内容，从工具原子化到上下文工程，从可观测性到子代理——**每一层都是在给模型"打补丁"，帮它收拾烂摊子。**

但这恰恰是最有意思的地方。

以前我觉得，AI 时代工程师的价值会下降。现在我觉得恰恰相反：**模型越强大，越需要工程能力来驾驭它。** 就像汽车引擎越来越强，但好的底盘、刹车、悬挂系统反而更重要。

**我们不是在和模型竞争，而是在和模型协作——它负责"能做什么"，我们负责"怎么让它稳定地做对"。**

所以，如果你问我做完这个项目最大的收获是什么？

不是学会了什么高大上的架构，而是想明白了一个朴素的道理：**优秀的 Agent 不是"让模型更自由"的产物，而是"把不确定性约束到最小"的结果。**

这个认知转变，可能比所有代码都值钱。
