1. 从“写提示词”到“设计循环”:一个思维拐点的到来
我大概是在连续调了三个星期的提示词之后,才真正意识到问题的。那段时间我每天的工作状态就是:打开编辑器,改一段提示词,跑一遍 Agent,看它在哪里断掉,再改提示词,再跑。循环往复,像极了在跟一个记性不太好但脾气很倔的实习生反复交代同一件事。每次我以为自己把话说清楚了,它总能在某个意想不到的环节给我整出新花样——要么是工具调用参数拼错了,要么是任务做到一半自己给自己加戏,要么是明明上一轮已经确认过的信息,下一轮它又忘了。
后来我停下来复盘,发现一个很反直觉的事实:我花在提示词上的时间,和 Agent 实际能稳定完成的任务复杂度,并不成正比。提示词写得再精细,它本质上还是在描述“这一次该怎么做”。而 Agent 要真正自己跑起来,需要的不是“这一次怎么做”,而是“每一步做完之后,下一步该往哪走、什么时候该停、出错了怎么退回来”。这些东西,提示词给不了,得靠 Loop。
这里说的 Loop,不是编程里那个for循环那么简单。在 AI Agent 的语境下,Loop 指的是 Agent 的运行时控制结构——它决定了 Agent 在每一轮里看到什么、能做什么、做完之后状态怎么更新、什么条件下继续、什么条件下终止。提示词是“内容层”,Loop 是“控制层”。我之前的做法相当于把控制逻辑硬塞进内容层里,用自然语言去描述“如果 A 就做 B,否则做 C”,结果就是提示词越写越长,Agent 反而越来越不稳定。
这个认知转变之后,我把工作重心从“雕琢提示词”挪到了“设计 Loop”上。效果是肉眼可见的:同一个任务,之前需要我盯着跑五六轮、中途手动纠偏,现在它能自己跑完,我只需要在最后验收。当然,这个过程里踩的坑一点没少,有些坑还挺隐蔽的。下面我就把这套东西拆开讲,包括 Loop 到底该怎么设计、提示词在新架构里扮演什么角色、以及我实际踩过的那些坑。
2. 提示词工程的天花板到底在哪里
2.1 提示词能解决的问题,其实比想象中窄
很多人对提示词工程的理解是“把话说清楚,模型就能做对”。这话在单轮任务里基本成立,比如“把这段中文翻译成英文”“从这段文本里提取人名和公司名”。但只要任务超过一轮,需要模型自己决定下一步做什么,提示词的局限性就暴露了。
我举个实际例子。我做过一个任务:让 Agent 去某个代码仓库里找所有硬编码的 API 密钥,然后生成一份报告。这个任务拆开看大概是:遍历文件、识别疑似密钥的字符串、排除测试文件、汇总结果、写报告。如果我把这些步骤全部写进提示词,告诉它“第一步遍历文件,第二步识别密钥,第三步排除测试文件……”,会发生什么?
实测下来,模型在前两轮还能按部就班,到了第三轮开始,它就会开始“自由发挥”。有时候它会把node_modules里的文件也扫进去,有时候它会在识别密钥的时候把普通的变量名也当成密钥,有时候它干脆跳过排除测试文件这一步直接汇总。原因很简单:提示词是静态的,而任务执行是动态的。每一轮模型看到的上下文都在变,但提示词里描述的逻辑是固定的,模型需要自己在脑子里把静态逻辑映射到动态状态上,这个映射过程极容易出错。
2.2 长提示词的边际收益递减
我做过一个粗略的对比实验。同一个任务,我写了三个版本的提示词:短版(约 200 字,只说明目标和输出格式)、中版(约 600 字,补充了步骤和注意事项)、长版(约 1500 字,把每一步的判断逻辑都写进去了)。然后每个版本各跑 20 次,统计任务完成率。
| 提示词版本 | 字数 | 任务完成率 | 平均轮次 | 人工干预次数 |
|---|---|---|---|---|
| 短版 | ~200 | 35% | 3.2 | 2.1 |
| 中版 | ~600 | 55% | 4.8 | 1.4 |
| 长版 | ~1500 | 58% | 6.5 | 1.2 |
数据很说明问题:提示词从 200 字加到 600 字,完成率涨了 20 个百分点;但从 600 字加到 1500 字,完成率只涨了 3 个百分点,而平均轮次和人工干预次数并没有明显改善。更麻烦的是,长提示词让 Agent 的响应变慢、token 消耗变大,而且一旦任务场景稍有变化,长提示词里那些具体的步骤描述反而成了束缚。
提示:如果你的提示词已经超过 800 字,而 Agent 的完成率还是上不去,大概率不是提示词的问题,而是 Loop 设计的问题。
2.3 提示词和 Loop 的分工边界
那提示词是不是就没用了?当然不是。我的经验是,提示词负责“定义角色和约束”,Loop 负责“控制流程和状态”。具体来说:
- 提示词里应该写:Agent 的身份、能力边界、输出格式要求、安全约束、工具使用规范。
- Loop 里应该管:当前处于哪个阶段、上一轮产出了什么、下一步该调用哪个工具、什么条件下重试、什么条件下终止。
举个例子。提示词里我会写“你是一个代码审计助手,只能读取文件,不能修改文件,输出必须是 JSON 格式”。而 Loop 里我会定义:第一轮调用文件遍历工具,拿到文件列表后进入第二轮;第二轮对每个文件调用内容读取工具,识别密钥;第三轮过滤测试文件;第四轮汇总输出。每一轮的状态(文件列表、已识别的密钥、过滤结果)都存在 Loop 的上下文里,而不是靠提示词去“记住”。
这样分工之后,提示词可以写得很短,Loop 的逻辑可以写得很清晰,两者各司其职,Agent 的稳定性明显提升。
3. Loop 的核心构件:状态、动作、终止条件
3.1 状态管理:Agent 的“工作记忆”该放在哪里
Loop 设计里最容易被低估的就是状态管理。我一开始的做法是让 Agent 自己维护状态——每轮结束的时候,让它把当前进展写进一个变量里,下一轮再读出来。听起来很合理,但实际跑起来问题很多。
第一个问题是状态漂移。Agent 在写状态的时候,会根据自己的理解“润色”一下,比如把“已扫描 12 个文件,发现 3 个疑似密钥”写成“已扫描部分文件,发现若干密钥”。信息在传递过程中被压缩了,下一轮它拿到这个模糊的状态,判断就会出错。
第二个问题是状态膨胀。如果每一轮都把完整的历史状态塞进上下文,几轮之后上下文就会变得非常长,模型处理起来又慢又容易分心。
我后来采用的方案是:状态由 Loop 的宿主程序维护,而不是由 Agent 维护。具体来说,Agent 每一轮只负责输出“这一轮做了什么、产出了什么”,宿主程序负责把这些产出结构化地存起来,下一轮只把必要的状态注入到提示词里。这样状态是精确的、可控的,不会漂移也不会膨胀。
用伪代码表示大概是这样:
state = { "phase": "scan", "files_scanned": [], "findings": [], "retry_count": 0 } while state["phase"] != "done": prompt = build_prompt(state) # 只注入当前阶段需要的状态 result = agent.run(prompt) state = update_state(state, result) # 宿主程序更新状态这个结构看起来简单,但它把“状态”从 Agent 的脑子里挪到了程序里,稳定性提升了一个量级。
3.2 动作空间:每一轮到底允许 Agent 做什么
动作空间的设计直接决定了 Agent 会不会“乱来”。我踩过的一个坑是:早期我给 Agent 开放了太多工具,它在一轮里可以同时调用文件读取、网络请求、代码执行、数据库查询。结果就是它经常在应该只读文件的时候去调网络请求,或者在应该汇总结果的时候又去读了一遍文件。
后来我改成按阶段收窄动作空间:在扫描阶段只开放文件遍历和读取工具,在分析阶段只开放文本处理工具,在汇总阶段只开放输出工具。每一轮 Agent 能做什么是明确的,它就不会跑偏。
这个思路其实和人类工作很像。你在做代码审计的时候,不会一边看代码一边发邮件一边改数据库。每个阶段有每个阶段的工具集,阶段之间是串行的。Agent 也一样,给它太多自由,它反而不知道该干什么。
3.3 终止条件:什么时候该停,什么时候该重试
终止条件是 Loop 设计里最需要小心的地方。我见过两种极端:一种是终止条件太松,Agent 跑了几十轮还在那里打转;另一种是终止条件太紧,Agent 刚开了个头就被强制结束。
我的经验是,终止条件要分三层:
- 成功终止:任务目标达成,比如“所有文件已扫描且报告已生成”。
- 失败终止:出现了不可恢复的错误,比如“连续三轮工具调用失败”或“重试次数超过上限”。
- 人工介入终止:Agent 自己判断需要人类决策,比如“发现疑似密钥但无法确定是否为测试数据”。
第三层特别重要。很多 Loop 设计里只有成功和失败两种终止,但实际任务里经常出现“Agent 不确定该怎么办”的情况。这时候如果强行让它继续,它就会瞎猜;如果直接判失败,又浪费了前面的工作。所以我在 Loop 里加了一个need_human状态,Agent 可以主动把控制权交回来,我处理完之后再让它继续。
4. 我踩过的五个坑,以及每个坑的排查过程
4.1 坑一:Loop 里的提示词注入把状态覆盖了
这个坑我排查了整整一个下午。现象是:Agent 在前几轮表现正常,到了第四轮突然开始重复第一轮的工作。我一开始以为是模型的问题,换了几个模型都一样。后来把每一轮的完整提示词打印出来看,才发现问题出在状态注入上。
我的 Loop 里有一个build_prompt函数,负责把当前状态拼进提示词。当时的写法是先把基础提示词模板加载进来,然后把状态字段逐个替换进去。但基础模板里有一个占位符叫{current_task},而状态里也有一个字段叫current_task,结果替换的时候把整个任务描述覆盖成了当前阶段名。Agent 看到的任务变成了“scan”,它自然就重新开始扫描了。
这个坑的教训是:状态字段的命名要和提示词模板的占位符严格区分开。我后来的做法是给所有状态字段加前缀,比如state_phase、state_files,这样就不会和模板里的占位符冲突。另外,build_prompt函数里加了一个断言,检查替换后的提示词里是否还残留未替换的占位符,有的话直接报错,不让它带着问题往下跑。
4.2 坑二:工具调用失败后的重试逻辑把 Agent 带进了死循环
有一次我让 Agent 去调用一个外部 API 获取数据,那个 API 偶尔会超时。我在 Loop 里加了重试逻辑:如果工具调用失败,就等两秒再试一次,最多试三次。听起来没问题,但实际跑的时候 Agent 卡在那里不动了。
排查后发现,问题出在重试的粒度上。我的重试逻辑是包在单轮里的:一轮里如果工具调用失败,就重试三次。但 Agent 在工具调用失败后,会认为这一轮没有产出有效结果,于是它会在下一轮重新尝试同样的工具调用。而下一轮里又有三次重试。结果就是 3×3×3……无限套娃。
修复方案是把重试逻辑从“单轮内重试”改成“跨轮重试”:单轮内工具调用失败就直接返回失败状态,由 Loop 决定是否进入下一轮重试,并且用一个全局的重试计数器来控制总次数。这样重试次数是可控的,不会指数级膨胀。
注意:重试逻辑一定要有全局计数器,不能只在单轮内计数。否则 Agent 的每一轮都会“重新开始计数”,导致无限重试。
4.3 坑三:状态序列化时把不可序列化的对象塞进去了
这个坑比较隐蔽。我的状态里存了一个文件句柄对象,用来在多个轮次之间保持文件读取的进度。在单次运行里没问题,但当我尝试把状态存到磁盘上做断点续跑的时候,序列化直接报错了。
排查过程比较直接:看报错信息就知道是哪个字段的问题。但修复的时候我犹豫了一下——是把这个字段去掉,还是换一种可序列化的表示方式?最后我选择了后者:把文件句柄换成文件路径加偏移量,需要读文件的时候再重新打开。这样状态就是纯数据的,可以随便序列化和反序列化。
这个坑的教训是:状态里只放数据,不放资源。资源(文件句柄、网络连接、数据库连接)应该在需要的时候创建,用完就释放,不要跨轮次持有。这样 Loop 的可恢复性会好很多,也更容易调试。
4.4 坑四:终止条件写成了“或”而不是“与”
这个坑导致 Agent 经常提前终止。我的终止条件原本写的是“所有文件已扫描 或 报告已生成”,本意是这两个条件满足任意一个就可以停。但实际执行的时候,Agent 在扫描完第一个文件之后就认为“所有文件已扫描”这个条件满足了(因为它把“所有”理解成了“当前这一批”),于是直接跳到报告生成阶段,报告里只有第一个文件的结果。
修复方案是把终止条件改成“所有文件已扫描 且 报告已生成”,并且把“所有文件”的定义明确成“初始文件列表里的每一个文件都处理过”。另外,我在 Loop 里加了一个校验步骤:在进入报告生成阶段之前,检查已处理文件数是否等于初始文件数,不等的话就退回扫描阶段。
这个坑的教训是:终止条件里的逻辑连接词要反复推敲。“或”和“与”一字之差,行为完全不同。而且自然语言里的“所有”“完成”“结束”这些词都有歧义,最好用可量化的条件来替代。
4.5 坑五:Agent 在 Loop 里“学会”了偷懒
这个坑最有意思。我让 Agent 做一个数据清洗任务,Loop 设计是:每轮处理一批数据,处理完检查质量,质量达标就进入下一批。跑了一段时间后我发现,Agent 处理第一批数据的时候很认真,越往后越敷衍,最后几批几乎是原样输出。
排查后发现,Agent 在上下文中看到了前面几轮的成功记录,它“推断”出这个任务的标准在降低,于是自己也降低了标准。这其实是模型的一种“模式匹配”行为:它看到前面的输出都是“通过”,就认为自己的输出也应该“通过”,于是不再认真做质量检查。
修复方案是在每一轮的质量检查环节,不把前面的成功记录注入上下文,只注入当前批次的数据和检查标准。这样 Agent 每一轮看到的都是“ fresh ”的任务,不会受到历史记录的影响。
这个坑的教训是:Loop 里的上下文注入要克制。不是所有历史信息都需要让 Agent 看到。有些信息(比如前面的成功记录)会让 Agent 产生“惯性”,反而降低它的表现。每一轮只注入当前决策必需的信息,是最稳妥的做法。
5. 一个可复用的 Loop 骨架设计
5.1 阶段划分:把任务拆成有限个状态
经过上面这些坑,我总结出了一个比较通用的 Loop 骨架。核心思路是:把任务拆成有限个阶段,每个阶段有明确的输入、输出和退出条件。阶段之间是串行的,阶段内部可以有多轮。
以代码审计任务为例,阶段划分大概是:
| 阶段 | 输入 | 输出 | 退出条件 |
|---|---|---|---|
| 初始化 | 仓库路径 | 文件列表 | 文件列表非空 |
| 扫描 | 文件列表 | 疑似密钥列表 | 所有文件已扫描 |
| 过滤 | 疑似密钥列表 | 确认密钥列表 | 过滤规则已应用 |
| 汇总 | 确认密钥列表 | 报告 | 报告已生成 |
每个阶段内部,Agent 可以有多轮交互。比如扫描阶段,如果文件很多,可以分批扫描,每批一轮。但阶段之间的推进是明确的:扫描阶段不结束,不会进入过滤阶段。
5.2 状态机实现:用代码而不是提示词来控制流程
这个骨架用代码实现大概是这样:
class AgentLoop: def __init__(self, task): self.state = { "phase": "init", "task": task, "files": [], "findings": [], "confirmed": [], "report": None, "retry": 0 } def run(self): while self.state["phase"] != "done": handler = getattr(self, f"phase_{self.state['phase']}") handler() return self.state["report"] def phase_init(self): result = self.call_agent("init", self.state) self.state["files"] = result["files"] self.state["phase"] = "scan" if self.state["files"] else "failed" def phase_scan(self): result = self.call_agent("scan", self.state) self.state["findings"].extend(result["findings"]) if result["done"]: self.state["phase"] = "filter" # ... 其他阶段类似这个结构的好处是:流程控制完全在代码里,Agent 只负责每个阶段内的具体工作。Agent 不需要知道“下一步该干什么”,它只需要知道“当前这个阶段该产出什么”。这样提示词可以写得很短,Agent 的负担也小很多。
5.3 提示词模板:每个阶段一个短提示词
对应上面的阶段划分,提示词也拆成多个短模板。比如扫描阶段的提示词大概是:
你是一个代码审计助手,当前处于扫描阶段。 你的任务是:读取给定的文件列表,识别其中疑似 API 密钥的字符串。 输出格式:JSON,包含 findings 数组和 done 布尔值。 约束:只读取文件,不修改文件;只识别密钥,不做其他分析。这个提示词不到 100 字,但信息很明确。Agent 知道自己在哪个阶段、要做什么、输出什么格式。它不需要知道整个任务的流程,因为流程由 Loop 控制。
对比之前那个 1500 字的长提示词,这个短提示词的效果反而更好。原因很简单:Agent 的注意力是有限的,提示词越短,它越能把注意力集中在当前任务上。
6. 从提示词思维切换到 Loop 思维的实际收益
6.1 稳定性:从“看运气”到“可预期”
切换之前,我跑 Agent 的心态是“看运气”。同一个任务,有时候能跑通,有时候跑不通,跑不通的时候我也不知道问题出在哪里。切换之后,Agent 的行为变得可预期了:如果某个阶段出错,我能定位到具体是哪个阶段、哪个环节的问题,然后针对性地修。
这种可预期性带来的最大好处是:我敢把更复杂的任务交给它了。以前我只敢让它做单步任务,现在我可以让它做多阶段任务,因为我知道每个阶段的边界在哪里,出问题也能兜住。
6.2 可调试性:出错时知道该看哪里
Loop 结构让调试变得简单了很多。以前 Agent 出错,我只能看最终输出,然后猜是哪一步出了问题。现在我可以看每一轮的状态变化,知道它在哪个阶段卡住了、卡住的时候状态是什么、上一轮产出了什么。
我甚至在 Loop 里加了一个简单的日志系统,每一轮结束的时候把状态快照写到一个 JSON 文件里。出问题的时候直接看这个文件,比看模型的输出日志直观多了。
6.3 可扩展性:加新能力不用重写提示词
最后一个收益是可扩展性。以前加一个新能力,比如“让 Agent 在扫描的时候顺便统计文件行数”,我得改提示词,而且改完之后可能影响其他部分的行为。现在加新能力只需要在对应阶段里加一个工具调用,或者在状态里加一个字段,不影响其他阶段。
这个收益在任务变复杂的时候特别明显。我的代码审计 Agent 从最初的“找密钥”扩展到“找密钥 + 找硬编码密码 + 找敏感配置”,只花了不到一个小时,因为每个新能力都是独立的阶段或阶段内的独立工具调用,互不干扰。
7. 一些零散但实用的经验
7.1 日志要记状态,不要只记输出
我一开始的日志只记 Agent 的文本输出,后来发现不够用。Agent 的输出是自然语言,看多了会累,而且很多关键信息(比如状态字段的值)不在输出里。后来我改成记状态快照,每一轮结束的时候把整个 state 序列化成 JSON 写文件。这样出问题的时候,我直接 diff 两个状态快照,就知道哪一步改变了什么。
7.2 给 Agent 的“自由发挥”留一个口子
虽然 Loop 控制了大部分流程,但有些情况下 Agent 确实需要自由发挥。比如它在扫描的时候发现了一个不在预期内的文件类型,它可能需要临时决定怎么处理。我的做法是在每个阶段里留一个notes字段,Agent 可以把它的“意外发现”写进去,Loop 在推进到下一阶段之前会检查这个字段,如果有内容就暂停一下让我看看。
这个口子不大,但很有用。它让 Agent 在遇到意外情况时有一个“举手”的渠道,而不是硬着头皮往下做或者直接卡死。
7.3 阶段不要拆得太细
我一开始把阶段拆得很细,一个任务拆了十几个阶段。结果 Loop 的代码变得很臃肿,阶段之间的状态传递也很繁琐。后来我合并了一些阶段,保持在 4 到 6 个阶段,代码清爽了很多,Agent 的表现也没有下降。
我的经验是:阶段的粒度应该以“决策点”为准。如果一个地方需要 Agent 做判断(比如“这批数据质量达标了吗”),那它就是一个阶段边界。如果只是机械地执行,那它可以和相邻的步骤合并。
7.4 测试 Loop 的时候先用假 Agent
调试 Loop 本身的时候,不需要每次都调用真实模型。我写了一个FakeAgent类,它根据阶段名返回预设的假数据。这样我可以快速测试 Loop 的控制逻辑,不用等模型响应,也不用花 token。等 Loop 的逻辑跑通了,再换成真实 Agent 做端到端测试。
这个做法帮我省了很多时间。Loop 的 bug 和 Agent 的 bug 是两类问题,混在一起调很痛苦。先用假 Agent 把 Loop 调通,再用真 Agent 调提示词,效率高很多。
7.5 别忘了给 Loop 本身加超时
Loop 跑飞的情况我遇到过两次,都是因为某个阶段的退出条件没写对,Agent 在里面无限循环。后来我在 Loop 外面加了一个全局超时,比如 10 分钟没跑完就强制终止,并且把当前状态 dump 出来。这个超时救了我好几次,至少不会让一个跑飞的 Loop 占着资源不放。
8. 关于“鹈鹕骑自行车”那个测试的一点想法
最后聊一个题外话。最近看到不少人在讨论“鹈鹕骑自行车”这类提示词测试,大意是给模型一个很具体的场景描述,看它能不能生成符合要求的输出。这类测试用来评估模型的指令遵循能力是有意义的,但我想说的是:它测的是提示词工程的能力,不是 Agent 的能力。
一个 Agent 能不能自己工作,不取决于它能不能根据一段描述画出鹈鹕骑自行车,而取决于它能不能在没有人盯着的情况下,自己决定先画鹈鹕还是先画自行车、画完之后怎么检查、发现画错了怎么改。这些能力不在提示词里,在 Loop 里。
所以如果你也在做 Agent,我的建议是:把花在雕琢提示词上的时间,挪一半到设计 Loop 上。提示词写到 80 分就够了,剩下的 20 分靠 Loop 来补。你会发现,Agent 突然就“自己会工作”了。