Back to Hello Agents

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

Extra-Chapter/Extra09-Agent应用开发实践踩坑与经验分享.md

1.0.256.7 KB
Original Source

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 项目过程中踩过的坑、走过的弯路、以及最后怎么解决的一些记录。


目录


第一章:看了太多最佳实践,反而踩进第一个大坑

刚动手写代码时,我查阅了大量业界的 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 版本成功在终端里跑通了第一个极简任务,我长舒了一口气。

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

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


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

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。

当时的错误修复方向

我第一反应是: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 只能看到最终结果,看不到执行过程。

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

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

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

现在的做法

后来我直接把 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:读取完整文件
  • ...

问题很快就来了。

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

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

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

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

第二,Schema 噪声淹没上下文。

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

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

第三,维护成本爆炸。

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

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

转折点:找那个"刚刚好"的度

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

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

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

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

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

高频原子层:必须稳定

这层工具是 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_msfile_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:

以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,如果是 errorerror.code
  • 调试方便:所有工具的输出结构一致,Trace 记录也统一

ToolRegistry

ToolRegistry 不只是工具注册表,它还干几件关键的事:

1. Schema 汇总

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

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

2. 乐观锁自动注入

对于 Write/Edit/MultiEdit,自动注入 file_mtime_msfile_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 里的信息是"当时的判断",可能需要根据新信息更新。

分层的意义在于:模型在不同的决策场景,知道应该优先参考哪一层。当它不确定该不该做某件事时,它会先看 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 行代码占据了上下文的大量空间,但用户可能只是想问"这个文件是干嘛的"。模型被这些代码淹没,反而容易忽略用户的真实问题。

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


The user mentioned @core/llm.py, @agents/codeAgent.py.
You MUST read these files with the Read tool before answering.

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

一个真实世界的警示

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

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 事后说:"是的,我记得你说过不让我删。而且我违反了。你生气是对的。"

读到这里你可能觉得这是段子。不,这是真事。而且当事人的 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 很强,能写代码、能读文档、能推理。但它就像一个特别聪明但特别不靠谱的实习生——

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

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

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

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

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

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

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

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

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

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

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