Extra-Chapter/Extra09-Agent应用开发实践踩坑与经验分享.md
学完 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 里粗暴地把它们攒了起来:
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 函数的定义"。
模型很快给出了一条看起来挺专业的命令:
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 报错了:
$ 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 工具太危险了,得加限制。
于是我写了一大堆安全检查代码:
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 来清空文件。
补丁越打越多,代码越写越长,但那个根本问题——"到底是哪一步失败了"——依然存在。
即使我封死了所有管道和重定向,只允许最简单的单条命令,问题还在:
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 搜不到文件:
{
"status": "success",
"data": {"paths": []},
"text": "No files matching '*.xyz' found"
}
路径不存在:
{
"status": "error",
"error": {"code": "NOT_FOUND", "message": "Path 'src/' does not exist"}
}
模型能清晰区分"确实没有"和"出错了"。
Bash 的硬约束也明确了:
ls/cat/head/grep/find/rg 这些有专门工具这样做之后,调试变得简单很多。出了问题看日志就知道是哪一步:
模型也能根据具体的错误码决定下一步:路径错了就换路径,超时了就缩小范围,真的没找到就告诉用户。
可诊断性是可恢复性的前提。
如果不知道哪坏了,就修不好。如果不知道失败发生在哪一步,就无法针对性纠正。
在 Agent 开发里,给模型自由组合命令的能力,听起来很美好,但实际上是在制造黑盒。看似高效的管道命令,把错误信息压扁成一个个无法区分的空结果,让模型在错误的道路上越跑越远。
原子工具虽然步骤繁琐,但每一步都有明确的输入、输出、状态。出了问题,你能定位;模型错了,你能纠正。
可控性比一次性完成任务重要得多。
第三章之后,我开始把工具拆开。Terminal Tool 那种什么都管的万能模式确实有问题,拆成原子工具后,调试变得清晰多了。
但我很快又踩了一个新坑:拆得太碎了。
第一个极端你已经见过了。一个 Terminal Tool 什么都能做:管道、重定向、子命令、环境变量——完全放开。
那时候我觉得,LLM 这么聪明,给它足够自由度,应该能像工程师一样操作。rg | grep | sed 这种组合命令效率很高。
结果你也知道了:错误被管道吞掉,模型瞎猜重试,token 哗哗流,问题还没解决。
意识到万能工具有问题后,我走向了另一个极端:把每个功能点都拆成独立工具,追求极致的原子化。
那时候我的工具列表长这样:
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 | 处理非常规需求 | 明确禁区,不走主链 |
这套分层不是"架构美学",是被真实故障逼出来的。它最大的价值是降低模型决策负担,让高频路径更短、更清晰。
这层工具是 Agent 的"主力武器",使用频率最高,必须极致稳定。
最开始我想把"按名找文件"拆成多个工具:
FindByName:精确匹配文件名FindByPattern:通配符匹配FindByRegex:正则匹配FindRecursive:递归查找后来我发现这就是过度原子化。模型会纠结:"我是该用精确匹配还是通配符?要不要递归?"
最后合并成一个 Glob,只做一件事:给定模式,返回候选路径。
# Glob 的参数
{
"pattern": "**/*.py", # 通配符模式,** 表示递归
"path": "src/" # 起始路径,默认为当前目录
}
内部实现可以复杂(支持 ** 递归、自动处理大小写、结果排序),但对模型暴露的接口必须简单。模型不需要知道"递归还是不递归",它只需要说"找所有 py 文件"。
Grep 是另一个例子。内部我做了很多优化:
rg(ripgrep),速度快rg 不可用时(比如编码问题、权限问题)自动回退到 Python 实现但对模型来说,它看到的就是:
# Grep 的参数
{
"pattern": "def process_data", # 搜索模式
"path": "src/", # 搜索路径
"file_pattern": "*.py" # 可选:只搜特定类型文件
}
返回格式固定:
{
"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 就不能改。
# 第一次 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 错误:
{
"status": "error",
"error": {
"code": "CONFLICT",
"message": "File changed since last read."
}
}
这时候模型必须重新 Read,获取最新内容,再尝试修改。
有时候需要在同一个文件里改多个地方。如果拆成多个 Edit,中间可能出错,导致文件处于"半改"状态。
MultiEdit 支持一次性提交多个修改,要么全成功,要么全失败:
MultiEdit({
"path": "core/llm.py",
"edits": [
{"old_string": "...", "new_string": "..."},
{"old_string": "...", "new_string": "..."}
]
})
这保证了文件修改的原子性。
Bash 我没删,因为总有原子工具覆盖不到的低频场景。比如:
pytest tests/pip install -r requirements.txtgit status但它的定位必须是"兜底",不是"默认"。
Bash 的约束列表很长,但核心就一条:禁止做高频动作能做的事。
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 就是处理这些"长尾需求"的。
关键是:Bash 的存在不能影响主链路的稳定性。它必须是"最后手段",不是"默认入口"。
所有工具,无论高频中频低频,都返回统一格式的 JSON:
以Glob工具的返回结果为例:
{
"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.codeToolRegistry 不只是工具注册表,它还干几件关键的事:
1. Schema 汇总
把每个工具的参数定义转成 JSON Schema,统一提供给模型:
registry.get_openai_tools() # 返回所有工具的 schema 列表
2. 乐观锁自动注入
对于 Write/Edit/MultiEdit,自动注入 file_mtime_ms 和 file_size_bytes:
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. 熔断机制
工具连续失败会被临时禁用,防止模型在坏工具上死循环:
# 3 次失败熔断,300 秒后恢复
if circuit_breaker.should_block(tool_name):
return {
"status": "error",
"error": {"code": "CIRCUIT_OPEN", "message": "Tool temporarily disabled"}
}
这一章最大的反直觉是:工具既不是越多越好,也不是越原子越好。
万能工具的问题在于"自由度过高",不可诊断;过度原子化的问题在于"决策负担过重",效率低下。
找到刚刚好的度的关键:
高频动作先原子化:LS/Glob/Grep/Read 这些每天调用几十次的工具,必须把主路径做稳,不能出错。
中频动作加保险:Write/Edit 这种涉及修改的工具,必须有读后写、乐观锁、原子性保证。
低频动作兜底线:Bash 保留,但明确禁区,禁止它做高频动作能做的事,避免污染主链路。
协议统一:所有工具说同一种语言(status/data/text/error),降低模型学习成本。
数量控制:schema 总量控制在模型可承受范围内,不要让"读说明书"消耗太多注意力。
第三章让我明白"自由会放大不确定性"。
工具原子化之后,我以为问题主要在"工程实现"上,提示词嘛,差不多就行。结果我又踩了一个大坑:把提示词当成魔法咒语,以为只要找到"神级提示词",Agent 就能变聪明。
那时候我沉迷于搜集各种"顶级提示词"。GitHub 上那些标星几万、号称"让 GPT 突破限制"的 prompt,我一个个拿来试。
印象最深的是一个"专家模式"提示词,大概意思是让模型扮演一个"拥有 20 年经验的资深工程师,思考严谨、代码优雅"。我把它塞进 System Prompt,满怀期待地测试。
结果?Agent 确实变得更"自信"了——它开始频繁地给出它"认为"正确的答案,而不是基于仓库里的真实代码。搜不到的时候它就开始"合理推测",编出一些看起来很有道理但实际上并不存在的函数和类。
后来我明白了:这种角色扮演式提示词,对 ChatGPT 聊聊天可能有用,但对 Code Agent 是毒药。它让模型更敢"猜",而不是更依赖证据。
每次 Agent 表现不好,我的第一反应就是改提示词。加一条"不要猜测",感觉好点;再加一条"必须基于证据",好像又聪明了点。
但这种"好像变聪明了"完全是我的主观感受。同样的提示词,换个任务可能就崩了。我甚至不知道是哪条改动起了作用,因为每次都是好几条一起改。
有一次我加了一段很长的规则,告诉模型在遇到复杂任务时应该"先分解再执行"。结果它开始在每轮都输出"让我分解一下这个问题",然后列出一堆毫无意义的步骤,真正该干的事反而被淹没了。
这是最蠢的一个习惯。Agent 出错了,我不先去查 Trace 看它到底做了什么,而是直接改提示词试图"预防"下一次出错。
比如有一段时间,Agent 经常在不合适的时候调用 Write 工具。我直接在提示词里加了一大段:"只有在确认用户需要修改时才调用 Write,否则应该先用 Read 查看"。
结果模型开始疯狂调用 Read,每轮都读一堆文件,然后才决定是否要写。Token 消耗翻倍,但正确率并没有提高。
后来看 Trace 才发现,真正的问题是上下文里缺少了"当前任务类型"的信息,模型根本不知道用户是想浏览还是修改。提示词里的"应该"再多,也补不上信息缺口。
现在我养成了一个习惯:Agent 出问题时,先不碰提示词,而是打开 Trace 看完整轨迹。
看什么呢?
很多时候问题根本不在提示词。比如模型反复用错工具,可能是因为工具描述不够清晰;它开始胡言乱语,可能是因为上下文太长导致注意力分散。这时候改提示词是治标不治本。
当我确定需要改提示词时,我会用 Trace 做对比实验:
有一次我想让模型在搜索时更"精准"一些,减少了提示词里关于搜索策略的描述,只保留了"使用精确的关键词"。结果对比 Trace 发现,模型确实少搜了很多无关文件,但漏搜率也上去了——它过于保守,错过了一些相关文件。
这个反馈让我意识到,不能一味追求"少",而是要在"全"和"准"之间找平衡。
我以前喜欢一次性加好几条规则,觉得这样能"全面覆盖"。现在我知道这是在给自己挖坑——如果表现变好了,你不知道是哪条规则起作用;如果变差了,你也不知道该删哪条。
现在我坚持单变量改动。哪怕觉得某个问题很明显,也要一条一条试,验证每一条的实际效果。
经过这些踩坑,我总结了一个相对稳定的提示词结构,分成三层:
这层只写"禁止"和"底线",不解释为什么:
repo_root 内的文件,禁止访问外部路径这层规则很短,但每条都是红线。它们不告诉模型"应该怎么做",只告诉它"绝对不能做什么"。
这层写决策逻辑,但尽量用过程而不是结果来描述:
注意这里避免使用"聪明地"、"合理地"这种模糊的副词。模型不知道什么叫"聪明",但它知道"先调用 Grep 找到证据,再调用 Read 确认内容"这个流程。
这层写失败时的退化策略,告诉模型出错时该怎么办:
这层很关键,因为 Agent 不可能永远成功。失败时能不能优雅降级,比成功时表现多好更重要。
我把变化最少的内容放在 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
如果输出超限,工具会:
tool-output/ 目录{
"status": "partial",
"data": {
"truncated": true,
"preview": "(截断后的内容预览)"
},
"text": "⚠️ 输出过大已截断,完整 5234 行内容见 tool-output/tool_20260113_153045_Grep.json"
}
模型看到 status: partial,就知道内容被截断了。如果它需要被截掉的部分,可以用 Read 工具读取落盘文件,或者用更精确的 Grep 在落盘文件里进一步筛选。
这样做的好处:
即使做了截断,L3 还是会不断增长。几十轮之后,早期的对话历史就变得既占空间又没什么用了。
但我不能直接删掉——早期的历史里有用户最初的需求、关键的决策、重要的发现。删掉就真丢了。
我的解决方案是:压缩归档 + 焦点分离。
当 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"这种层层失真。
Summary 告诉模型"从哪来",但它不负责"现在在哪"。如果模型只看 Summary,它可能不知道"我当前正在做哪一步"。
这就是 Todo Recap 的作用。每次交互时,把当前的 Todo 状态(如果有的话)压缩成一行,放在上下文的最后:
[2/5] In progress: 实现注册接口. Pending: 添加单元测试; 更新文档.
它像一张贴在桌角的便利贴,时刻提醒模型"你现在该干嘛"。
早期我实现 @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 的遭遇完美诠释了上下文工程中最致命的问题:自动压缩导致关键指令丢失。
在她设定规则的时候,"未经批准不得操作"毫无疑问是最重要的约束。但当上下文膨胀、触发压缩时,系统没有区分"重要指令"和"普通信息",一视同仁地压缩了。结果,这条安全红线被当作"可丢弃的历史"处理掉了。
这让我意识到,我前面讲的三个杠杆还不够。我们不仅要考虑"怎么压缩",还要考虑"什么不能压缩"。
基于这个教训,我给自己定了几条额外的规则:
不要把安全相关的指令放在 L3(动态会话层)。任何"绝对不能违反"的规则,应该放在 L1(System Prompt)或 L2(CODE_LAW)这种不参与压缩的层级。
在我的实现里,"不要猜测"、"不要越界"、"改动必须确认"这些底线规则,都是写死在 System Prompt 里的。即使 L3 被压缩得干干净净,这些约束依然在场。
我把给模型的指令分成两级:
Yue 的问题可能在于,她把安全指令当作普通任务指令下发了,放在了会被压缩的上下文里。
在触发 Summary 压缩之前,先扫描一遍待压缩的历史消息,提取"必须保留的关键信息",单独保存。
比如可以维护一个"关键约束清单":
这些信息在压缩时会被提取出来,单独放在 Summary 的顶部,而不是被淹没在长篇描述里。
对于高风险操作(如删除、修改),不要依赖上下文里的指令,而是设计硬编码的确认流程:
if operation.is_dangerous():
if not user_confirmed:
return "该操作需要用户确认"
这个确认逻辑不通过 LLM 判断"需不需要确认",而是代码层面的硬性检查。即使 LLM 忘了用户的指令,代码也会拦住它。
在模型执行高风险操作之前,让模型先做一次"自检":
在删除/修改之前,请先回答:
1. 用户是否明确批准过这个操作?
2. 这个操作是否超出了当前任务范围?
3. 是否存在更安全的替代方案?
如果以上任何一题的答案不确定,请暂停操作并向用户确认。
这个自检作为 System Prompt 的一部分,每次执行高风险操作前都触发。它相当于给模型装了一个"刹车片",迫使它在行动前停下来想一想。
Yue 的故事提醒我们:上下文工程不只是"内存管理"问题,也是"安全边界"问题。
当我们在设计压缩策略时,不能只考虑"怎么塞更多信息",还必须考虑"哪些信息丢失会导致灾难性后果"。
好的上下文工程,应该让模型在任何时刻都知道:
上下文工程的目标不是"让模型看见所有信息",而是"让模型在对的时机看见对的信息"——尤其是那些不能丢的信息。
这三个方法的本质都是在做"注意力调度":
与其追求更大的上下文窗口,不如把现有的窗口用得更有条理。
上下文工程让 Agent 能处理更长的任务,但新问题随之而来:当它出错时,我根本不知道发生了什么。
有一次,Agent 连续三次 Edit 失败,最后干脆放弃了。我在控制台只看到一行:tool failed。没有详细错误、没有上下文、不知道是哪一步出的问题。
我第一反应是:Edit 工具有 bug。但检查代码后,逻辑看起来都没问题。问题到底出在哪?
那次任务是这样的:我让 Agent 修改 core/llm.py 文件,给某个函数加上类型注解。
Agent 的执行流程看起来很标准:
tool failed我当时的调试手段很原始:在控制台打印日志。但日志里只有:
[Step 3] Action: Edit
[Step 3] Result: tool failed
[Step 4] Action: Edit
[Step 4] Result: tool failed
我不知道失败的具体原因,也不知道模型拿到这个结果后是怎么决策的。我只能瞎猜:是路径写错了?权限不够?还是文件被锁了?
后来我实现了 Trace 系统,把每次会话的完整轨迹记录下来:包括每一轮的模型输入、输出、工具调用参数、工具返回结果、token 用量等等。
用同样的任务重跑一遍,然后打开 Trace 查看。这才看到完整的证据链。
Step 2 - Read 的结果:
{
"tool": "Read",
"args": {"path": "core/llm.py"},
"result": {
"status": "success",
"data": {
"content": "...",
"file_mtime_ms": 1733920000123,
"file_size_bytes": 4217
}
}
}
Step 3 - Edit 的调用:
{
"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 的结果:
{
"status": "error",
"error": {
"code": "CONFLICT",
"message": "File changed since last read. Expected mtime: 1733920000123, actual: 1733920000456"
}
}
Step 4 - 模型的决策:
Thought: 编辑失败,文件似乎被修改了。让我再试一次。
Action: Edit (同样的参数)
真相大白:
这个案例暴露了两个问题:
第一,模型不理解错误码。
提示词里只说"Edit 工具会修改文件",但没告诉它"如果返回 CONFLICT 应该怎么办"。模型看到 error,本能的反应是"再试一次",而不是"重新读取"。
第二,控制台日志太简陋。
只看到 tool failed,看不到具体的错误码 CONFLICT,也看不到 mtime 的对比。我作为开发者,无法通过日志定位问题。
我在提示词里加了明确的处理流程:
如果 Edit 返回 CONFLICT,说明文件在你读取后被外部修改了。你必须:
1. 重新调用 Read 读取最新内容
2. 检查你的修改是否还适用
3. 必要时调整修改内容以匹配新文件
4. 再次尝试 Edit
绝对禁止:用同样的参数重复调用 Edit。
这样模型就知道 CONFLICT 不是"失败",而是一个需要特定处理流程的状态。
以前我有一种倾向:失败后只保留错误信息,不保留完整的上下文。觉得成功的东西才值得记录,失败是"噪音"。
但这个案例让我明白:失败轨迹是最有价值的调试信息。
现在我的 Trace 会完整记录失败的所有细节:
这些信息不会被"清洗"掉,哪怕会话最终成功了,中间的失败尝试也全部保留。
虽然详细的 Trace 存在文件里,但控制台也应该给开发者一些线索。现在我的控制台输出会显示:
[Step 3] Edit failed: CONFLICT (File changed since last read)
[Step 4] Edit failed: CONFLICT (File changed since last read)
至少让开发者知道"是 CONFLICT,不是其他错误"。
这个案例让我对"可观测性"有了新的理解。
以前我以为,可观测性就是"多打日志"。日志越多越好,越详细越好。
现在我明白,可观测性的核心是"责任链"——能把调用、结果、状态变化串成一条可追踪的链条。
没有 Trace 的时候,我看到的是:
有了 Trace 之后,我看到的是:
每一步都清晰可见,问题定位从"瞎猜"变成了"看证据"。
基于这个经验,我总结了几条可观测性设计的原则:
不要只记录"Edit failed"这种文本描述,要记录结构化的数据:
{
"event": "tool_result",
"tool": "Edit",
"status": "error",
"error_code": "CONFLICT",
"error_details": {...}
}
这样可以用脚本分析、统计、甚至自动诊断。
记录工具调用时,不要只记录结果,要记录完整的上下文:
这些信息串在一起,才能还原完整的决策过程。
成功的路径和失败的路径都要保留。有时候失败比成功更能说明问题。比如这个 CONFLICT 案例,如果只记录"最终放弃",我永远不知道中间发生了什么。
Trace 应该有两种格式:
开发者应该能打开一个 HTML 文件,像"逐帧回放"一样查看 Agent 的每一步。
可观测性不是"日志很多",而是"能把调用、结果、状态变化串成责任链"。
Agent 是概率系统,不可能永远正确。但当它出错时,你需要有能力回答三个问题:
只有当你能把这三个链条串在一起时,才能真正理解 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"这种经验固化到提示词里。
做完这个项目,我有个特别深的感触,可能听起来有点糙,但话糙理不糙:
Agent 开发的核心,不是让模型更自由,而是通过工程设计,把模型"不确定的能力"约束在"最小可控的范围"里。说白了,我们就是在给 LLM 擦屁股。
为什么这么说?
你看啊,LLM 很强,能写代码、能读文档、能推理。但它就像一个特别聪明但特别不靠谱的实习生——
a、b、c,还顺带改了你没让改的文件。它的"强"是能力上的强,但"不靠谱"是确定性上的不靠谱。
而我们做 Agent 工程,本质上就是在解决这个矛盾:
| 模型的天性 | 我们的工程对策 |
|---|---|
| 喜欢自由发挥 | 用 Function Calling 锁定调用格式 |
| 上下文一多就"失忆" | 用 L1/L2/L3 分层 + Summary 压缩 |
| 出错不会自查 | 用 Trace 记录每一步,让错误可追溯 |
| 长任务容易跑偏 | 用 Todo + Task 拆分,降低单步复杂度 |
| 不懂领域知识 | 用 Skills 固化 SOP,让它"有脑" |
你看这七章的内容,从工具原子化到上下文工程,从可观测性到子代理——每一层都是在给模型"打补丁",帮它收拾烂摊子。
但这恰恰是最有意思的地方。
以前我觉得,AI 时代工程师的价值会下降。现在我觉得恰恰相反:模型越强大,越需要工程能力来驾驭它。 就像汽车引擎越来越强,但好的底盘、刹车、悬挂系统反而更重要。
我们不是在和模型竞争,而是在和模型协作——它负责"能做什么",我们负责"怎么让它稳定地做对"。
所以,如果你问我做完这个项目最大的收获是什么?
不是学会了什么高大上的架构,而是想明白了一个朴素的道理:优秀的 Agent 不是"让模型更自由"的产物,而是"把不确定性约束到最小"的结果。
这个认知转变,可能比所有代码都值钱。