# 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 行代码占据了上下文的大量空间,但用户可能只是想问"这个文件是干嘛的"。模型被这些代码淹没,反而容易忽略用户的真实问题。 现在我改成:只插入提醒,不直接注入内容。 ``` 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 事后说:"是的,我记得你说过不让我删。而且我违反了。你生气是对的。" ![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 不是"让模型更自由"的产物,而是"把不确定性约束到最小"的结果。** 这个认知转变,可能比所有代码都值钱。