跳到主要内容
路径文档

Agent 主循环

一句话版@deepseek-ai/dsh-agent-loop 是 harness 里唯一的具体循环:它驱动会话/轮次/步骤生命周期,其余一切都是抽象服务或插件;新行为写插件,别改这里

这是全站最该读透的一篇。理解了 agent-loop,你就理解了"一条用户消息到底走过了什么"。

一、为什么它是"唯一的具体循环"

源码 README 的一句话定义了整个 harness 的架构边界:

这是 harness 中唯一包含具体循环逻辑的包。其他所有内容要么是抽象服务,要么是针对扩展点的插件:新行为应放入插件,而不是这里

这意味着什么:

  • 核心极薄agent-loop 只做一件事:"调模型、跑工具、循环"。它不替你想"要不要搜索、要不要压缩、要不要问用户"。
  • 一切皆扩展点:搜索(web)、压缩(compact)、恢复(llm-retry)、沙箱/权限(guard)、子 Agent(subagent)、UI 渲染……全部是挂在事件上的插件。
  • 这条边界是有意的:想在"循环内部"加东西,先问自己:能不能用现有事件/工具流水线做到?99% 能。真正要改循环本身的需求极少。

一句话记:DSH 的哲学 = agent-loop 管"驱动",其余的全靠插件组合

二、会话 / 轮次 / 步骤 三级生命周期

先建立基本词汇,后面都会用到:

  • 一个会话承载一个持久的对话,可以 resume(崩溃后从日志重建)或 fork(派生新会话)。
  • 一个轮次从你发一条消息开始,到 agent 完整回应结束;轮次之间用编号连续。
  • 一个步骤是轮次内部的最小推进单位:要么是一次模型推理,要么是一次工具执行。一个多工具轮次 = 模型步骤 → 工具步骤 → 模型步骤 → 工具步骤…直到 agent 认为完成。

agent-loop 的 README 原话:它"驱动会话/轮次/步骤生命周期"。这三级是事件溯源日志里最底层的结构(见 事件系统)。

三、两种创建方式:声明式 vs 编程式

agent 不是一个"new 出来的对象",它有两条完全不同的出生路径,理解这决定了你怎么用。

3.1 声明式(config 驱动)

agent-loop 插件的 config.agents 里声明:

- id: agent-loop
name: '@deepseek-ai/dsh-agent-loop'
config:
maxParallelToolCalls: 10 # 默认 10;1 = 串行
agents:
- id: main
provider: deepseek-official
model: deepseek-v4-flash
maxTokens: 65536 # 每个请求的输出 token 上限
cwd: /path/to/workspace
- id: resume-me
resumeSessionId: <已有会话 id> # 恢复这个持久化会话
  • 声明的 agent 在服务启动时自动开始(ctx.agentLoop.create() 内部创建)。
  • 所有权:agent 归循环 fiber 所有,handle 被丢弃。
  • 没有 per-agent persona / setup hook:声明式 agent 用部署 persona;要按 agent 定制 persona/工具组合,只能走编程式。

3.2 编程式(ctx.agents)

通过工厂接口 ctx.agents(agent-loop 把自己注册为 ctx.agents.setFactory(this)):

// 程序化创建
const handle = await ctx.agents.create({
sessionId, // 全局唯一;可与并发操作并列准备,enter() 裁决
meta?, // cwd / lineage / seed-boundary 元数据
seed?, // fork 出的子会话前缀(重建历史用)
agentOptions?, // per-agent provider/model/tools
setup?, // 受信任的同进程组合代码(可遮蔽 persona)
signal?, // 只作用于 load/setup/发布阶段
})

// 恢复持久化会话
const resumed = await ctx.agents.resume({
resumeSessionId, // 必填:已存在的持久化会话 id
agentOptions?,
setup?,
signal?,
})
  • 编程式的 AgentHandle唯一消费侧 teardown 能力:持有它的人才负责结束这个 agent 的生命周期。
  • signal 只作用到 promise settle 为止(发布/加载期),不会在 handle 变得可见之后继续取消。

所有权对比

声明式编程式
入口config agents:ctx.agents.create/resume
启动服务启动自动你调用时
所有权循环 fiber持有 AgentHandle 的调用方
persona部署 persona 固定setup/agentOptions 可遮蔽
典型场景稳定主 agent子 agent、运行时按需

四、事务性生命周期:创建与恢复是一回事

最重要的一个事实:createresume 属于同一个受回滚保护的事务。不是"创建会话,然后请求"两个步骤,而是一个不可分割的提交。

create / resume(同一受回滚保护事务)
├── 构造私有会话 + concrete agent + 带作用域上下文
├── await setup(可选的受信任同进程组合代码;不得驱动未发布的 agent)
├── 进入两个 registry(agents / sessions)
├── 发布顺序:session/created → agent/created → agent/session-start
└── 只有到这一步,才开始驱动循环

teardown(结束时的对称序列)
停止并 drain → 撤销作用域 → detach agent → detach session

关键的并发与回滚不变量(源码原话要点):

  • sessionId 全局唯一;两个并发操作可以用同一个 id 同时准备,但最终的 enter() 调用裁决发布:每个失败者把自己的私有资源完整回滚。
  • resumeSessionIdsessionId 互斥:一个会话要么新建、要么按已存在 id 恢复。
  • detach 绑定到确切进入的对象:一个陈旧的 disposer 无法误杀后来的同 id 替代项:这保证了"卸载一个、重建一个"不会互相踩踏。
  • 创建期间的同步通知里请求 detach,会等待该 dispatch 展开,保持 agent/createdagent/disposed 配对完整。
  • 传递的普通 identity/options 按 readonly 契约借用;但 seed 事件与会话元数据会被校验并快照,因为它们要跨过持久的会话边界。

常见理解误区:以为"resume 是重新读一遍历史再跑新轮次"。其实是:加载持久化会话 → 在全新的未发布 agent 作用域上 await setup → 再进受回滚保护的发布。所以 resume 出来的 agent 和新建的走同一条安全路径,不折不扣。

五、可续性:resume 的边界

ctx.agents.resume({ resumeSessionId, ... })
  • 它经 ctx.sessionPersistence 加载持久化会话,按同一 id 注册 agent,重建历史,然后 await setup,再受回滚保护的发布。
  • 轮次编号和派生历史从加载的日志继续(不是从 0)。
  • 硬依赖:resume 需要一个 session-persistence 后端。不过它不是硬注入的:没有持久化的 demo 照样能跑,只是 resume 会明确 reject(报"persistence absent")。这让你既能写纯内存 demo,又能在需要时无缝启用 resume。

实战价值:崩溃 / 重启后,一条 resume 让对话从持久化日志里继续,而不是丢失。(dsh-harness-ops 等仓库外生态的相关 skill 无法从本源码验证,请自行核对。)

六、send 原语与 inbox:消息怎么进到循环

agent 收到内容不是直接塞给模型,而是经过一套基于 inbox(FIFO)的发送原语。源码把这一切收敛到一个统一的 send() 原语,按 target × wakeup 路由,followup/steer/inject 是它的三个固定别名:

原语targetwakeup语义
followup()next-turn FIFO追加一条"下一轮"输入并唤醒驱动
steer()next-step inbox追加并唤醒(驱动进入下一步)
inject()next-step inbox追加但不唤醒;等 follow-up/steer 一起

轮次边界上的取用规则:

  • 在轮次边界,驱动打开 durable turn,然后原子地认领待处理的 next-step 输入 + 一个排队的 prompt。
  • 在两个 step 之间,只认领 next-step 输入(不碰下一轮)。

inbox 的事件化:每一次变动都先发标准化的切换事件,再改 live 投影:

事件时机
agent/inbox/inserted { message }每次插入
agent/inbox/claimed { message, turn }每次消息被认领进入 step
agent/inbox/discarded { message }普通移除(带 outcome:'canceled')
agent/inbox/spliced每次变更前的统一预告(插入/编辑/移除/认领/取消都用同一套 splice 坐标)

MessageId 在两个 pending 列表(下一轮 / 下一步)间全局唯一;同步的 durable-event 观察者可以从 pre-splice 投影重建被移除的值。

七、驱动循环与请求装配:一次 step 里发生了什么

当 inbox 里有一批输入被认领进一个 step,驱动开始组装这一次模型请求

7.1 装配什么样的请求

对每个 step,循环发送:

per-agent 系统提示词 (systemPrompt.assemble() 渲染)
可见工具 schema (经 tools 呈现,native/code/both)
会话的派生消息 (surface 消息,turn/step 依据见下文)

循环只补充 provider/model/cwd 这几个变量,不加额外固定散文。完整的装配瀑布(agent/request + systemPrompt.assemble())见 上下文系统

7.2 adapter-default 标记:让 HMR 不串味

一个很容易被忽略但很关键的机制。在一次 agent/request 返回 provider/model 调用配置后,循环调用 ctx.llm.prepareCall() 来:

  • 校验 adapter 自有字段
  • 物化配置的 reasoning-effort 与 output-token 默认值(在活跃 turn 信号下)

然后 request/header 记录有效配置和哪些字段来自 adapter。在下一个 waterfall 之前,循环会移除这些标记的字段,让当前精确 route 重新物化自己的默认值:而未标记的显式设置会跨 step / 跨 route 保留。

这一次异步解析期间,HMR(热重载)也就不会把一个 adapter 的能力结果混到另一个 adapter 的请求。没有这条,改配置热更时可能拿到"上一家的默认值"。

7.3 完成锚点

每个成功到达 finish 的 provider 调用,都恰好追加一个 assistant/message 完成锚点:包括无内容的调用和 max-tokens 触顶的调用。锚点记录按原样组装的内容、在 sourceEventSeqs 里列出精确的 chunk seq(无 chunk 的流是 []),有 usage 就带上;空内容不进入派生历史。

八、失败、恢复与取消

agent-loop 对待失败的核心原则:插件的失败结束的是"当前轮次",不是整个循环

8.1 两类失败,两条路径

失败来源走向
最终 adapter 选择 / dispatch / 迭代失败作为 terminal error 或 aborted finish 进入 agent/request-error
middleware / 结果处理 / 工具 / 其它扩展抛错直接关闭,不进 agent/request-error

agent/request-error 的恢复:一个处理 listener 可以返回 { kind: 'retry' }(等 exact-provider 正常或无界退避,由 dsh-llm-retry 实现,会 emit 非 surface 的 llm/retry 状态);没有 listener 处理 = 终态失败

8.2 取消语义

agent.cancel(cause, { keepInbox })
  • 有效取消会清除 pending 工作(除非 keepInbox),并协作式地 abort 当前 сигнал
  • 空闲时取消是 no-op
  • abort 后、activity 收敛到 idle 前落地的唤醒输入会被锁住(wakeRequested) 并在驱动自身收敛边界重放:不必再发一次唤醒
  • disposed 取消从不锁住
  • 已 idle 时再提交唤醒,总会打开轮次边界(状态会显示瞬时的 idle → running → idle)
  • durable turn/end:userparent 记录 aborted,disposal 记录 disposed
  • 未派发的模型工具调用收到合成的 tool/call + ABORTED_BEFORE_DISPATCH result 对:所以模型在后续步骤里不会对着"消失的调用"困惑

8.3 并行执行的两类

step 内,工具调用分两类:

  • exclusive(独占):形成屏障,前后严格串行
  • parallel-safe(并行安全):用有界的滚动池并行;执行前被重新分类

只有 dispatch/body 是并行的;policy、durable result、result context 保持模型顺序。isConcurrencySafe(args) 的语义就在这里(见 工具执行)。

已知限制:unary 分类:依赖"跟兄弟比较"才能判安全的调用必须保持 exclusive。没有内置 turn 预算:要限制失控轮次,得从 agent/turn-stopping 等既有扩展点取消。

九、哪些该交给插件(扩展面)

循环明确"只做到 调模型-跑工具-再循环"。其余必须挂在事件上。这是 DSH 最重要的编程约定:

要做的事挂在哪
钩子 / 策略agent/* 检查点 + tools/pre-executetools/executetools/post-executefinalizeContenttools/result 流水线
上下文压缩压力在 agent/pre-step;溢出的规范修复在 agent/request-error
模型请求恢复dsh-llm-retryagent/request-error 记录并等待退避
沙箱 / 权限 / plantools/pre-execute(deny/ask)、tools.guard()tools/post-executetools/result
子 Agentctx.subagents provider(in-process 用 ctx.agents.create() + owned handle);后台由 ctx.jobs + dsh-tool-subagent
持久化eager write-behind 自 session/event;session/flush 是显式观察屏障
UIsession/event(token 流/边界/工具活动)+ agent/* 控制事件(agent/statuscreated/disposed)

测你懂没懂:想给 agent 加一个"每条消息先检索记忆再回答"的功能:不是改 agent-loop,而是在 agent/request(装配瀑布)或 agent/pre-step 上挂插件去注入检索结果。这正是 上下文系统 讲的事。

十、Model Experience:模型 / token / KV-cache 三视角

理解这几个效应,你才知道"为什么某些配置会费 token、为什么改 schema 会全量失效前缀缓存"。

维度模型看到token 效应KV cache 效应
完整请求per-agent 系统提示词 + 可见工具 schema + 会话派生消息系统文本和 schema 每 step 都重付只有系统文本/schema/历史逐字节不变(同 provider+model route)才 append-only 复用
保留的历史被接受的 user/assistant 消息、工具调用与结果、注入上下文、steering每个 surface 消息都会增长输入;多工具轮次每 step 重发累积历史普通历史增长 append-only 可复用;surface 替换或压缩会使前缀失效
取消未派发调用ABORTED_BEFORE_DISPATCH 错误码 + 固定文本每个跳过调用留一个固定 error result,直到压缩append-only,不失效既有条目

拿这个去理解 上下文系统 里说的"压缩摘要复用请求头对齐 prefix-cache":目的就是保持系统文本/schema/历史逐字节不变,让 provider 的 KV cache 不失效。

十一、源码结构速览

agent-loop 包的内核(ReactLoopAgent、它的 inbox、run controls)是包内部的,包根只导出 plugin/service/config 契约,exports map 没有 ./src/* 逃生口。生命周期所有者在 ctx.agents 层面创建,而不是命名/构造/启动驱动内核。这意味着:

  • 你永远通过 ctx.agents / config 与循环交互
  • 一个已准备的会话只能被一个具体驱动认领
  • 所有可观察的东西都通过 session events 与 agent/* 事件 暴露(不会给你访问内部状态)

十二、配置速查

agentLoop:
maxParallelToolCalls: 10 # 每个 agent 并行安全调用的滚动池上限;1=串行
agents:
- id: main
provider: deepseek-official
model: deepseek-v4-flash
maxTokens: <正整数,可省略> # 每个请求的输出 token 上限,记在 request/header
cwd: <可选,m仅对新会话生效>
resumeSessionId: <可选,与 sessionId 互斥>

配置要点(源码):

  • agents 故意不在 Settings 段里:它在服务启动时被消费一次,改存值只会"看起来"有效。maxParallelToolCalls 是 agent-loop Settings 段的全部,热改它就能给下一个工具组封顶(且非法值在写入时就被拒)。
  • 配置 agent 的 provider/model/cwd 会作为 prompt 变量提供;harness identity 与部署 persona 属于 dsh-system-prompt
  • 一次模型调用要求 provider + model 都齐;agent/request 可以在 dispatch 前补缺失的一对。

十三、验证

# 1. 看会话事件流(默认 zstd 压缩、两级 --<cwd>--/<id>/ 目录)
zstdcat ~/.dsh/sessions/*/*/session.jsonl.zstd | tail -30

# 2. 看一轮对话里 session/turn/step 的三级结构
zstdcat ~/.dsh/sessions/*/*/session.jsonl.zstd \
| jq -r 'select(.type | test("turn/|step/|assistant/message|tool/call|tool/result")) | [.type, (.seq|tostring)] | @tsv'

# 3. 看 agent 控制事件
zstdcat ~/.dsh/sessions/*/*/session.jsonl.zstd | grep -E "agent/" | head

下一步

  • 工具执行:循环里工具怎么被调用(流水线 + 并行/取消)
  • 上下文:模型每次请求看到什么(装配瀑布 + KV cache)
  • 事件agent/*tool/* 两套事件
  • 子 Agent:用 ctx.agents.create() 派活(编程式入口的实际用法)