MingJunDuan的博客
热爱可抵一切,探索未知之境
全站访问量

Agent 的循环与目标:一个 Agent 到底是怎么自己跑起来的

两个场景,先请你感受一下”跑起来”和”答出来”的差别。

场景 A:你在聊天框里问”Redis 主从复制怎么实现?”。模型给你一段洋洋洒洒的回答,你点个赞,对话结束。整个过程一次调用、一次返回,干净利落。

场景 B:你说”把项目里所有日志改成带 traceId 的格式,跑一遍单测,失败的发我报告。”模型先翻目录、定位日志框架、改代码、跑测试、解析失败信息、再改、再跑……中间可能有几十次工具调用,跨度可能是一小时。你中途去开了个会,回来发现它已经把报告整理好了,还顺手把没改干净的两处标了出来。

同样是”一个输入”,A 是一次函数求值,B 是一场持续运转。前几篇我们讲了大模型本身(《从大模型到 Agent》)、讲了大模型没有状态(《大模型无状态》)、讲了 Agent 的记忆(《Agent 记忆》)。但这三样加起来,还只回答了”它能知道什么、记住什么”,没回答那个最核心的问题:

它凭什么能从”答一次”,变成”自己把事情推进到底”?

答案在 Agent 身上剩下的最后两块拼图里:循环(Loop)目标(Goal)。循环是引擎,让 Agent 一轮接一轮地转下去;目标是方向盘,告诉它往哪转、转到哪算到站、什么时候该停下喊人。这篇文章就把这两块拆开讲,仍然用 DeepSeek Harness(下文简称 DSH)的真实实现做对照。


一、先厘清两块拼图:引擎与方向盘

先回到上一篇《从大模型到 Agent》里给过的一个朴素定义:

Agent = 大模型 + 工具 + 记忆 + 目标,并且跑在一个循环里。

这句话拆成五样东西。我们已经讲完了”大模型”“工具”“记忆”三样,现在剩下两样恰好是一对:

拼图 回答的问题 类比
循环(Loop) 它靠什么一轮接一轮地干活? 引擎
目标(Goal) 它往哪干、干到哪算完、何时停? 方向盘

为什么要分成两块讲?因为它们解决的其实是两个不同维度的问题:

  • 循环是”机制”——它保证”感知 → 思考 → 行动 → 观察”这个闭环能稳定地转下去,能转几十轮不掉链子,能中途被取消,能在崩溃后原样重放。
  • 目标是”意图”——它保证这个转起来的机器,知道自己是为了什么在转:任务完成了没有、要不要继续、卡住了要不要放弃。

一个只有循环没有目标的 Agent,是个”多动症”:会一直调用工具,永远停不下来,也不知道停在哪。一个只有目标没有循环的 Agent,是个”空想家”:知道自己要干什么,但没有手段一步步推进。两者缺一不可。

下面分别拆。


二、循环:Agent 的”心跳”

2.1 为什么单次问答不够

大模型本身是一个纯函数

f(tokens) -> tokens

给它一串 token,它返回一串 token,然后结束。它不会”想起来还有下一步”,因为它根本没有”下一步”这个概念。你要它读文件、改代码、跑测试,它一次只能”说”出一段文字,说完了就完了。

所以 Agent 要做的第一件事,就是在模型外面包一层壳,让这个纯函数被反复调用:每一次调用都带上”到目前为止发生了什么”,让模型基于最新的事实再思考一次,决定下一步动作,然后这个动作的结果又被喂回下一次调用。这个反复调用的壳,就是循环。

用一张图表示,就是那个经典的感知-思考-行动-观察闭环:

          ┌─────────────────────────────────────────────┐
          │                Agent Loop(引擎)             │
          │                                              │
   输入 ──▶│ 思考 Think ──▶ 行动 Act(调用工具)            │
          │    ▲                 │                       │
          │    │                 ▼                       │
          │    └──── 观察 Observe(工具结果喂回模型)◀─────┘
          │                                              │
          └──────────────────┬──────────────────────────┘
                             │
                    目标达成?否 ──▶ 再转一圈
                    目标达成?是 ──▶ 停下、汇报

这个图我们之前画过,但今天要往下钻一层:这个圈,具体是怎么被一台计算机一帧一帧地”跑”出来的?

2.2 两层节拍:turn 与 step

DSH 里真正驱动循环的类叫 ReactLoopAgent。它最核心的设计,是把”一圈一圈地转”拆成了两层节拍

层级 名字 粒度 谁在变
外层 turn(回合) 一次”用户意图”的完整处理 用户消息、目标轮次触发
内层 step(步) 一次”模型调用 + 工具执行” 模型每想一步、调一批工具

为什么要分两层,而不是一层?因为一次用户输入,往往会触发很多次模型调用

你说”改日志并跑测试”,Agent 的第一个 step 可能是”读一下项目结构”;工具返回文件列表后,第二个 step 是”定位日志框架”;第三个 step 是”改第一处代码”…… 这一个个 step 连起来,才构成一个完整的 turn。turn 代表”你这件事”,step 代表”为这件事走的一小步”。

用一个时间线图看得更清楚:

用户消息 ─┐
          ▼
┌─ turn 1 ──────────────────────────────────────────────┐
│  step 1    step 2          step 3                      │
│  思考      思考            思考                          │
│  ↓         ↓              ↓                            │
│  ls 文件   读 pom/logback   改第一处代码                  │
│  ↓         ↓              ↓                            │
│  观察结果  观察结果         观察结果                      │
└────────────────────────────────────────────────────────┘
          │ 工具返回"没有更多要做的了"
          ▼
       turn 结束,返回给用户

分层的意义,在于每一层都能独立地被”打断”和”恢复”:一个 step 没跑完,可以取消;一个 turn 没跑完,可以往里塞新指令。这为后面要讲的”取消”“可回放”铺了路。

2.3 循环由谁驱动:状态机、收件箱、取消信号

循环要稳定地转,靠三样东西支撑。

第一样,是一个明确的状态机。 ReactLoopAgent 在任何时刻只处于三种状态之一:

idle(空闲) ──有人投递消息──▶ running(运转中)
   ▲                              │
   │◀──────── 一圈跑完 ───────────┘
   │
   └──▶ maintenance(维护中:如写盘检查点)──▶ 回到 idle

有了明确的状态,才能回答那个绕不开的问题:“此刻这个 Agent 到底是不是在忙?” 当你想往里塞新指令,如果它在 idle,那就直接开新一圈;如果它在 running,那就把消息先放进队列,等它忙完再处理。这个”忙不忙”的判定,是一切并发和取消逻辑的地基。

第二样,是一个收件箱(Inbox),而且它有两格。 DSH 的 Inbox 不是”一个先进先出的队列”,而是两个队列,对应两种不同的投递意图:

队列 含义 谁来投 典型场景
next-turn 等当前回合结束后,作为新回合处理 followup() 用户追加了一句新要求
next-step 在当前回合内,作为下一步直接插队处理 steer() / inject() 用户中途纠正方向、工具结果回填

这个区分极其关键。想象 Agent 正在改代码(turn 还没结束),你突然说”等等,别改 UserService 了”。这句纠正必须立刻插进当前回合的下一步,而不是等它把手上这一圈全跑完再开个新回合——否则它已经按旧意图改完了。所以 steer() 投的是 next-step。而你说”顺便再帮我把单测也补了”,这是件新的事,可以等当前这件干完再说,投 next-step 还是 next-turn 就取决于你要不要它打断当前工作。

一句话记住:两个队列,本质是在控制”新消息要不要打断当前正在做的事”。

第三样,是一个贯穿全程的取消信号(AbortSignal)。 循环里的每一步——发请求前、收流式块时、执行工具前、执行工具后——都会检查一次这个信号。你一按停止,信号触发,正在进行的模型调用被中断,已经排队的工具被跳过,但已经执行完的工具结果不会凭空消失。这个”取消了但不乱账”的特性,直接引出了 2.5 的可回放。

2.4 工具在循环里如何被调度

模型一次思考,可能同时想调用好几个工具——”读 A 文件”“读 B 文件”“查一下 C 接口”,这三个互不依赖,显然可以并行。但也可能”先删文件再读文件”,顺序绝对不能乱。

DSH 对工具调度的处理,浓缩成三条规则:

  1. 声明了”排他”(exclusive)的工具,是一个屏障:它必须等前面的工具全部跑完、且它跑完之前后面的不能开始。这保证了”先写后读”这类顺序依赖。
  2. 声明了”并行安全”(parallel)的工具,进一个滚动池:池子有上限(默认同时最多 10 个),跑完一个补一个,不浪费也不压垮系统。
  3. 不管工具实际的执行顺序多乱,结果都按模型当初想要的顺序提交回去。 因为模型是按”第 1 个调用、第 2 个调用……”的语义写的答案,喂回去的结果也必须按这个顺序,否则模型会错乱。

更细节的一点:当取消发生时,那些”还没轮到执行”的工具调用,会被补写一条合成的失败结果(”工具调用在派发前被中止”)。这听起来是小事,其实是为了保证会话记录是完整的——后面 2.5 会看到,完整记录是可回放的前提。

2.5 循环为什么必须是”可回放”的

DSH 的循环有一个贯穿性的设计原则:循环里的每一个动作,都会变成一条带序号的持久事件,按顺序写进会话日志。

turn/start → step/start → user/message → assistant/chunk×N
          → assistant/message → tool/call → tool/result
          → step/end → ... → turn/end

这条事件流,就是 Agent 的”完整录像”。它带来三个直接的工程收益,正好呼应之前讲过的几篇:

  • 可回放:Agent 的”当前状态”不是存在某个易失的内存变量里,而是从日志事件一条条重放折叠出来的。进程崩了、重启了,只要日志还在,就能精确重建出”它刚才干到哪了”。这也是《Agent 可观测性》里 trace 能追溯到底层的原因。
  • 可取消:因为每一步都有边界,取消就能在边界处干净地落下,不会留下一笔糊涂账。
  • 可纠正:因为消息可以插到 next-step,你就能在任意一步”掰”它的方向盘,而不必等它跑完。

到这里,循环这块拼图讲完了。但你会发现一个问题:循环只解决了”能一直转”,没解决”转到什么时候”。 该停了、该继续了、卡住了要喊人——这些判断谁来做?答案在下一块拼图。


三、目标:Agent 的”方向盘”

3.1 引擎有了,还差方向盘

一个只有循环的 Agent,会有一个很尴尬的行为:它不知道”完没完”。

模型说”我已经改完日志了”,这是真的改完了,还是它偷懒说改完了?它卡在一个测试失败上,是继续尝试,还是果断放弃告诉你?这些判断,循环本身给不了——循环只负责”转”,不负责”方向”。

于是 DSH 在循环之上,又加了一层目标(Goal)子系统。它的职责一句话说清:

把一个”长跑型任务”变成一个可被管理、可被追溯、可被自动推进的持久对象。

什么叫”长跑型任务”?就是那种一次用户请求说不完、需要 Agent 自己连续干很多轮才能完成的任务。比如”把整个项目迁移到新的构建系统”“修复这一批共 20 个 bug”。这种任务有一个共同点:它不可能在一轮对话里干完,Agent 需要一轮一轮地自动推进,而且中途随时可能被用户暂停、纠正、恢复。

3.2 目标的两个维度:phase 与 activation 分离

这是整个目标子系统里最精妙的一个设计。DSH 把一个目标的”状态”拆成了两个正交的维度

维度 是否持久化 含义
phase(阶段) active / paused / blocked / complete ✅ 写进日志 目标客观上处于什么状态
activation(授权) armed(武装)/ disarmed(解除) ❌ 只存在内存 目标是否被允许自动推进

为什么要拆开?因为这里藏着一个安全上的关键问题:“这个目标是 active 的”和”这个 Agent 现在有资格自己往下干”,是两件不同的事。

举个具体场景:你昨天让 Agent 跑一个长任务,跑到一半你关了电脑。今天重新打开,会话从日志里恢复出来了,目标还记着,phase 还是 active。这时候——它该不该自己接着干?

答案是:不该。因为”恢复会话”这个动作是你(或系统)做的,不是你亲口说”继续”。如果 Agent 一恢复就自动接着干,那任何一次进程重启、会话 fork、甚至一个后台 bug 导致的重新挂载,都会让它在你不知情的情况下重新开工。这非常危险。

所以 DSH 的规则是:activation 绝不持久化。 无论日志里记的目标 phase 是什么,进程一重启、会话一恢复,activation 一律重置为 disarmed。目标还在(phase 没丢),但”自动续命的资格”没了。要让它重新自动推进,必须有人(你,或你明确授权的动作)显式地 resume 一下,把 activation 重新 armed

一句话记住:phase 回答”它现在是什么状态”,activation 回答”它现在有没有资格自己动”。状态可以跨重启记住,资格不能。

3.3 目标怎么被记住:事件溯源 + 快照 + CAS

目标的状态存在哪?和循环一样——存在会话日志里,不单独建数据库。

DSH 的做法是典型的事件溯源(event-sourcing):每次目标发生变化,就向日志追加一条 goal/change 事件,这条事件里携带变更后的完整快照(objective、phase、revision、round 数、时间戳等)。读取目标时,不是去查某个”当前值”,而是把日志里所有 goal/change 事件按顺序重放折叠一遍,得到最新状态。

为什么用事件溯源而不是”存一个 current 变量”?因为它和循环的可回放是同一套哲学:日志是唯一的真相源。 目标怎么从”active”变成”blocked”、中间改过几次 objective,全都有据可查。

但这带来一个新问题:并发和并发冲突。 假设模型正在自动推进,同时你想改目标;或者两个地方同时想改同一个目标。谁说了算?

DSH 用了经典的比较并交换(CAS,Compare-And-Swap)来兜底。每个目标带一个引用 GoalRef { id, revision },其中 revision 每次变更都 +1。任何一次修改,都必须带着”我基于的当前 revision”来:

  • 如果你的 revision 已经旧了(说明中间有人改过),修改直接拒绝,报”陈旧引用”错误。
  • 只有 revision 对得上,才允许提交,并生成 revision+1 的新快照。

这就像你去柜台办业务,手里必须拿着”当前排号单”,号过期了就不给你办。一个 revision 字段,就把”目标可能被多方同时修改”的乱账问题,干净地防住了。

更进一步,DSH 的重放折叠是”严格”的:如果日志里出现非法状态迁移(比如从 active 直接跳到 complete 中间没有合法动作)、revision 不连续、时间戳回退,重放会当场报错而不是默默容忍。这是完整性检测,不是防黑客——它保证的是”如果你发现日志被改坏了,我能立刻发现,而不是静默地给你一个错误的目标”。

3.4 目标怎么自动推进:goal round

phase 和 activation 是”状态”,但光有状态不会干活。让目标真正”自己往前跑”的,是一个叫 goal-round-driver 的驱动器。

它的工作方式,可以概括成一句话:在 Agent 空闲的时候,自动往循环里再投喂一轮。

具体流程是这样的:

  1. 驱动器盯着一件事:Agent 是不是空闲了(回到 idle),同时目标的 phase 是 active 且 activation 是 armed,且还没用完轮次预算。
  2. 条件满足,它就生成一条特殊消息投进去。这条消息长这样:
<goal_round>
Objective: "把项目迁移到新构建系统"
Round: 3/256

Continue working toward the objective in this same session...
(继续在这个会话里推进目标……)
</goal_round>
  1. 这条消息通过 followup() 进入前面讲的同一个循环,被当成新一轮的”用户意图”处理。模型看到”第 3/256 轮,继续推进”,就接着上一轮的结果往下干。
  2. 干完这一轮,Agent 又空闲了,驱动器再投第 4 轮……如此往复,直到模型自己判定”目标完成”并标记 complete,或者触发了轮次上限(默认 256 轮)被 blocked

这里最值得品味的一点是:goal round 复用了循环,而不是另起一套执行机制。 目标驱动器自己不”执行”任何东西,它只是那个”到点了就往引擎里投一轮”的人。引擎还是那个引擎,方向盘只是每隔一段路轻轻拨一下。

但这个”自动投喂”也埋着竞态风险:万一用户恰好在你投喂的同时发了一条新消息,谁先谁后?万一你投的这一轮已经被用户取消了?DSH 为此做了一套精细的竞态防护:每一轮都有 queued(已排队)→ claimed(已领取)→ admitted(已准入) 的生命周期,投进去的消息若发现”被用户的消息挤掉了”“目标已经变了”“这一轮已经不是合法的下一轮”,就会判定为 stale(失效),把它撤回来,而不是硬着头皮执行一个过时的目标。这套逻辑的核心目标是同一句:自动推进绝对不能抢在人的意图前面。

3.5 模型怎么操作目标:三个工具与权限门槛

目标既然是给模型”自己管理自己”用的,那模型就必须有操作目标的工具。DSH 给了模型三个:

工具 作用 谁能调
get_goal 读当前目标(含 id/revision/phase/轮次/阻塞原因/是否 armed) 模型随时
create_goal 创建一个新目标 只能在人类直接请求时
update_goal 改目标(edit/pause/resume/complete/blocked) 分动作看权限

这里最关键的,是第三行 update_goal 背后的权限模型。它区分两种”权威来源”:

  • direct-human(人类直接指令):这个 turn 里有真人发的消息。此时 edit、pause、resume 这些”改方向”的动作才被允许。
  • goal-round(目标自己的轮次):这个 turn 是驱动器自动投喂的(消息来源是 goal),不是人发起的。此时模型只能做两件事complete(完成)或 blocked(阻塞)——也就是”收尾”,不能擅自”改方向”。

这个区分,堵住了一个很常见的漏洞:Agent 在自动推进时,不能自己悄悄改目标、悄悄暂停、悄悄换个更容易的目标糊弄你。 它可以在干完时宣布”完成了”,也可以在干不动时报告”卡住了”,但唯独不能”擅自改写任务本身”。改写任务,必须是人的事。

而且,连”宣布卡住”都被加了门槛:模型要报 blocked,必须同一个阻塞条件连续持续了至少 N 轮(默认 3 轮)才能报。这是为了防止模型一遇到点小困难就甩锅”我卡住了”——困难、不确定、还有活可干,都不算 blocked。它得先自己多试几轮,证明真的走不通,才有资格喊人。

当模型最终报 completeblocked 时,系统还会往它的上下文里注入一段收尾指令<goal_complete> / <goal_blocked>),强制它在结束前给用户写一段像样的交代:做了什么、怎么验证的、结果在哪、接下来需要用户做什么。这样目标收尾时,不会”干完了却一声不吭”。

3.6 人怎么操作目标:/goal 命令

目标也给人留了入口——一个斜杠命令 /goal,用来随时查看和干预:

/goal                      # 查看当前目标
/goal 重构认证模块          # 创建一个新目标
/goal edit 重构支付模块     # 改目标
/goal pause                # 暂停
/goal resume               # 恢复(重新 armed)
/goal clear                # 清除

人可以用它暂停一个跑偏的目标、纠正目标描述、或在它卡住时手动恢复。它和模型工具走的是同一个底层服务,所以人看到的、模型看到的,永远是同一份目标状态——不存在”模型以为目标 A,你以为目标 B”的分裂。


四、全景:记忆、循环、目标三者怎么咬合

到这里,Agent 的最后两块拼图补齐了。把整个系列串起来,一张全景图:

                    ┌──────────────────────────────────────────────┐
                    │               Agent(那层"壳")                │
                    │                                              │
   ┌────────────┐   │   ┌──────────────────────────────────────┐   │
   │  记忆 Memory │───┼──▶│        循环 Loop(引擎)              │   │
   │  分层存储    │   │   │   turn → step → 思考→行动→观察        │   │
   │ (08-27 篇) │   │   │   状态机 + 双队列 + 取消 + 可回放       │   │
   └────────────┘   │   │         ▲              ▲              │   │
                    │   │         │ 读/写        │ 投喂/纠正      │   │
   ┌────────────┐   │   │   ┌─────┴──────┐  ┌────┴──────────┐   │   │
   │  目标 Goal  │───┼──▶│  │  工具 Tools │  │ 目标驱动器      │   │   │
   │  phase+授权 │   │   │  │ (08-20 篇)│  │ goal-round 自动 │   │   │
   │ (本篇)    │   │   │  └────────────┘  │ 推进(本篇)     │   │   │
   └────────────┘   │   │                   └───────────────┘   │   │
                    │   └──────────────────────────────────────┘   │
                    └──────────────┬───────────────────────────────┘
                                   │
                        LLM(无状态纯函数 f(tokens)→tokens)

三者的关系,用一句话收口:

  • 记忆决定 Agent 知道什么——它分层存储,让循环每一圈都有可用的上下文(08-27 篇)。
  • 循环决定 Agent 怎么动——它是引擎,把”想、做、看”串成可持续的闭环(本篇前半)。
  • 目标决定 Agent 为何动、何时停——它是方向盘,给引擎一个方向和一个刹车(本篇后半)。

缺了记忆,循环每转一圈就失忆;缺了循环,目标和记忆都只是”躺在纸上的计划”;缺了目标,循环就是一个停不下来的空转马达。


五、心法总结

最后,把这篇的两块拼图各提炼成一句可带走的心法:

循环是引擎:把”单次补全”变成”持续运转”,靠的是两层节拍(turn/step)、双队列(next-turn/next-step)和贯穿全程的取消与回放。

目标是方向盘:把”运转”变成”有方向的运转”,靠的是 phase 与 activation 的分离、事件溯源 + CAS,以及”自动推进永远让位于人”的权限边界。

而这两句背后,其实藏着同一条更底层的设计哲学,也是整个 DSH 乃至任何严肃 Agent 框架的立身之本:

Agent 的一切状态,都要能被记录、被重放、被取消、被人类随时接管。自动,不是放任;智能,不是失去控制。

模型负责聪明,壳负责可靠。聪明的部分这几年进步飞快,而可靠的部分——循环怎么转、目标怎么管、状态怎么记——才是真正决定一个 Agent 能不能”放心地放手让它自己跑”的地方。

如果你是从《从大模型到 Agent》一路读到这里,那么”大脑、工具、记忆、循环、目标”这五块积木,至此就全部拼齐了。剩下的,就是把它们装进真实业务时才会浮现的那一层层脏活——鉴权、可观测、投毒防御——这些,前面的 MCP 系列和 Agent 系列里也已经陆陆续续埋下了引子。

本文阅读量