别把 Agent 写成一个大循环:读懂 DeepSeek Harness 的接口与运行时设计

33 阅读12分钟

写一个能调用工具的 Agent,最初似乎并不复杂:

// 示意代码
async function runAgent(messages) {
  while (true) {
    const response = await callModel(messages)

    messages.push(response)

    if (!response.toolCalls.length) {
      return response
    }

    const results = await executeTools(response.toolCalls)
    messages.push(...results)
  }
}

模型提出工具调用,程序执行工具,再把结果交给模型。

但随着系统开始支持网页界面、SDK、子 Agent、会话恢复、取消操作和插件卸载,这个循环很快就不再只是循环。

你会遇到一些更难回答的问题:

  • 谁负责创建 Agent?
  • UI 应该依赖哪个类?
  • 创建失败后,谁清理已经申请的资源?
  • 创建 Agent 的插件卸载了,Agent 是否应该继续运行?
  • 修改循环内部的状态机,会影响多少调用方?
  • 不启动模型和工具,能否单独测试创建入口?

DeepSeek Harness 对这些问题的回答,首先体现在一个拆分上:

使用 Agent 的公共接口,与实现 Agent 的默认运行时,是两件不同的事。

本文顺着一次真实的创建调用,分析这个设计怎样落到代码里,以及它究竟解决了什么问题。

一、先把四个容易混淆的名字分开

阅读代码前,先区分四个角色:

名称角色
Agent单个活跃 Agent 的公共接口
AgentRegistry提供 ctx.agents,负责公共创建入口及活跃对象注册
AgentLoop默认驱动服务,实现 Agent 工厂
ReactLoopAgent默认的单个 Agent 实现,负责运行循环

其中最重要的对应关系是:

AgentLoop implements AgentFactory

ReactLoopAgent implements Agent

AgentLoop 不是那个直接实现 Agent 接口的类。

它更接近一个负责创建、恢复和管理默认 Agent 生命周期的服务。真正接收输入、推进状态、运行步骤的是 ReactLoopAgent。

如果把这几个角色混在一起,后面的代码会显得比实际复杂。

二、从调用方开始:创建者需要知道多少东西?

先看项目中的 ACP 会话模块。

你不需要了解 ACP 协议,只需要把它当成一个“需要创建 Agent 的业务接入层”。

它的创建逻辑如下:

static async create(
  ctx: Context,
  options: CreateAcpSessionOptions,
): Promise<AcpSession> {
  const modelControl = new AcpModelControl(
    ctx.llm,
    options.fallbackSelection,
  )

  const handle = await ctx.agents.create({
    sessionId: options.sessionId,
    meta: { cwd: options.cwd },
    agentOptions: options.agentOptions,
    signal: options.signal,
    setup: async (agentCtx) => {
      modelControl.install(agentCtx)
      await mountAcpMcpServers(
        agentCtx,
        options.mcpServers,
        options.cwd,
      )
    },
  })

  return new AcpSession(
    ctx,
    handle,
    modelControl,
    options.notify,
  )
}

这段代码表达了四个需求:

  1. 创建一个指定身份的会话及 Agent。
  2. 设置工作目录和模型选项。
  3. 在正式发布前安装这个会话需要的能力。
  4. 返回一个可以使用和清理的对象。

注意,这里没有:

new ReactLoopAgent(...)

也没有模型请求循环,更没有手动构造内部状态机。

调用方说的是:

我需要一个符合公共约定的 Agent。

而不是:

我要亲自组装默认循环实现。

这是整套设计的起点。

三、公共创建入口并不直接构造对象

继续追踪 ctx.agents.create()。

它对应 AgentRegistry 的方法:

async create(
  options: CreateAgentOptions,
): Promise<AgentHandle> {
  const ownerCtx = this.ctx
  const { target } = this.requireFactory()
  const receiver = getTraceable(ownerCtx, target)

  return Reflect.apply(
    target.createAgent,
    receiver,
    [ownerCtx, options],
  )
}

第一次阅读时,可以先不深入 Cordis 的服务追踪机制。

把主要逻辑压缩一下,它相当于:

// 示意代码
async function create(options) {
  const factory = requireRegisteredFactory()

  return factory.createAgent(
    callerContext,
    options,
  )
}

这里最重要的事实是:

注册表没有导入具体类并直接执行 new,而是委托给已注册的工厂。

如果注册表内部直接写:

// 反例示意
async function create(options) {
  return new ReactLoopAgent(...)
}

那么即使调用方使用了统一的 create() 入口,公共层仍然与默认实现绑定。

想换实现,还是得修改注册表。

现在,注册表只要求“有一个能够创建 Agent 的工厂”,不需要知道这个工厂内部怎样完成工作。

四、工厂接口为什么属于公共包?

工厂约定的核心结构很小:

export interface AgentFactory {
  createAgent(
    ownerCtx: Context,
    options: CreateAgentOptions,
  ): Promise<AgentHandle>

  resume(
    ownerCtx: Context,
    options: ResumeAgentOptions,
  ): Promise<AgentHandle>
}

它定义两个操作:

  • 从创建选项生成一个新的 Agent。
  • 从持久化会话恢复一个 Agent。

两者都返回 AgentHandle。

这里比接口内容更值得关注的是:接口由公共 Agent 包定义,而不是由默认 AgentLoop 实现包定义。

于是,源码依赖方向变成:

ACP 等调用方 ──────────→ Agent 公共包
                             ↑
                             │
默认 AgentLoop 实现包 ────────┘

调用方依赖公共约定,默认实现也依赖公共约定。

公共层不必反过来导入默认实现。

这就是依赖倒置在这里的具体表现:不是抽象地“多写一个 interface”,而是明确了由谁拥有双方共同遵守的接口。

当然,TypeScript 接口在运行时不会自动完成连接。真正连接两边的是注册动作。

五、默认实现如何接入?

默认驱动服务明确声明自己实现工厂:

export class AgentLoop
  extends Service
  implements AgentFactory {
  static inject = [
    'agents',
    'sessions',
    'llm',
    'tools',
    'systemPrompt',
    'sessionProjections',
  ]

  // ...
}

在构造过程中,它将自身注册到 Agent 服务:

ctx.effect(
  () => ctx.agents.setFactory(this),
  'agentLoop.setFactory()',
)

至此,关系变成:

加载 AgentLoop 插件
        ↓
将 AgentLoop 实例注册为工厂
        ↓
AgentRegistry 持有工厂
        ↓
后续 create() 调用转发给该工厂

注册逻辑还包含两个重要约束。

第一,已经存在工厂时,拒绝注册第二个:

if (this.factory !== undefined) {
  throw new Error(
    'an agent factory is already registered',
  )
}

第二,注册附带清理动作:

this.factory = { target }

return () => {
  this.factory = undefined
}

这说明工厂注册不是一个永久的全局赋值,而是受到插件生命周期管理的贡献。

也需要注意其适用范围:

一个注册表的工厂槽位同一时间只能有一个工厂。这不是按请求选择多个驱动的路由器,也不意味着可以无缝替换正在运行的 Agent 对象。

可替换的接入点与运行中无损迁移,是不同的能力。

六、真正的 new 被放在哪里?

注册表委托给工厂后,会进入:

AgentLoop.createAgent(ownerCtx, options)

默认实现准备 Session、处理存储资源,再进入创建和发布流程。

实际构造对象的关键代码是:

const created = machine = new ReactLoopAgent(
  loopCtx,
  id,
  options,
  session,
)

现在,调用链完整了:

ACP 会话模块
        │
        ▼
ctx.agents.create(options)
        │
        ▼
AgentRegistry.create(options)
        │
        ▼
已注册工厂的 createAgent(ownerCtx, options)
        │
        ▼
AgentLoop 的创建与发布流程
        │
        ▼
new ReactLoopAgent(...)

new 没有消失。

它只是被放到了真正拥有构造知识的地方:默认实现内部。

这很重要,因为创建 Agent 不只是调用构造函数,还涉及:

  • 准备 Session。
  • 获取持久化写入资源。
  • 创建局部作用域。
  • 运行调用方提供的 setup。
  • 注册并发布对象。
  • 等待初始化监听器。
  • 失败时回滚已经准备的资源。

调用方不需要重复理解这些步骤,只需通过公共入口表达需求。

七、返回的是公共能力,不是内部状态机

默认对象声明如下:

export class ReactLoopAgent implements Agent {
  readonly inbox: ReactLoopInbox

  private phase: Phase

  private activityDone: Promise<void> =
    Promise.resolve()

  // ...
}

其中,phase 是实现细节。

调用方使用的公共 Agent 接口,则提供输入、取消、状态和等待等能力。

把相关声明合并并裁剪后,可以理解为:

// 公共接口的局部示意,非完整定义
interface Agent {
  readonly id: SessionId
  readonly session: Session
  readonly status: AgentStatus
  readonly ctx: Context

  followup(message: UserMessage): void
  steer(message: UserMessage): void
  inject(message: UserMessage): void

  cancel(
    cause: AgentCancelCause,
    options?: CancelOptions,
  ): void

  whenIdle(): Promise<void>
}

公共接口描述调用方可以依赖的行为。

默认实现负责兑现这些行为。例如:

followup(input: UserMessage): void {
  this.send(input, 'next-turn', true)
}

steer(input: UserMessage): void {
  this.send(input, 'next-step', true)
}

inject(input: UserMessage): void {
  this.send(input, 'next-step', false)
}

从公共层看:

  • followup() 提交普通后续输入。
  • steer() 提交下一步接纳的引导输入。
  • inject() 补充上下文,但不主动唤醒驱动。

从默认实现看,它们被映射到不同队列和唤醒选项。

调用方依赖的是前一组语义,而不是后一组内部结构。

因此,默认实现可以调整内部状态机,但不能随意改变公共方法的行为。

八、为什么返回 AgentHandle,而不是裸 Agent?

创建接口返回:

export interface AgentHandle {
  agent: Agent
  dispose(): Promise<void>
}

这里有两层信息。

第一,agent 的公开类型是 Agent,不是 ReactLoopAgent。

真实对象可以使用默认实现,但调用方通过公共接口使用它。

第二,销毁能力放在 Handle 上。

这区分了两件事:

持有 Agent
    → 可以与它交互

持有 AgentHandle
    → 还拥有创建方的销毁入口

观察一个 Agent 的模块,不一定是拥有它生命周期的模块。

例如:

  • UI 可能只是展示运行状态。
  • 插件可能只是注入上下文。
  • 编排器可能才是创建和回收它的所有者。

把所有权显式表达出来,比让所有拿到 Agent 的代码都能随意销毁对象,更容易维护。

需要补充的是,默认工厂提供者也承担结构性的生命周期责任:它卸载时,需要停止并等待自己创建的相关对象完成清理,而不是只清空一个引用。

九、这套设计的实际收益是什么?

“以后可以换实现”当然是一项收益,但不是最直接的一项。

真正的收益已经出现在当前代码中。

1. 缩小内部重构的影响范围

假设默认驱动需要修改:

  • 输入队列结构。
  • 状态机表示。
  • 调度方式。
  • 请求重试的内部组织。

只要继续满足公共 Agent 行为,ACP 这类调用方就不需要跟着修改内部细节。

这不意味着公共接口永远不会变,而是把“实现重构”和“公共行为变更”区分开。

2. 集中管理复杂初始化

创建者提供配置及 setup,默认工厂负责完整的构造事务。

否则,不同入口可能各自复制:

创建 Session
创建 Agent
装工具
注册对象
发布通知
处理失败

这些流程一旦分散,很容易出现某个入口遗漏清理、提前发布对象,或者在失败时留下半初始化状态。

统一创建入口减少了这种差异。

3. 可以独立测试公共创建层

仓库中已有测试先加载 AgentRegistry,但不安装默认 AgentLoop:

const ctx = new Context()
await ctx.plugin(AgentRegistry)

await expect(
  ctx.agents.create({
    sessionId: SessionId('s'),
  }),
).rejects.toThrow(/no agent factory/)

然后注册测试工厂:

const { factory, calls } = stubFactory()

ctx.agents.setFactory(factory)

这样就能测试:

  • 没有工厂时是否明确失败。
  • 创建和恢复是否正确委托。
  • 选项是否正确传递。
  • 调用方的生命周期上下文是否被保留。

这些测试不需要启动模型请求循环。

这说明工厂接口不仅服务于替换生产实现,也让公共层的责任可以被单独验证。

4. 生命周期所有权能统一传递

再回头看这个参数:

createAgent(ownerCtx, options)

为什么需要显式传递 ownerCtx?

因为“工厂由谁提供”和“这个 Agent 由谁创建并拥有”不是同一个问题。

假设全局加载了 AgentLoop,而一个临时业务插件创建了 Agent。

业务插件卸载后,这个 Agent 不应该因为全局工厂仍然存在,就变成无人负责的后台工作。

默认实现通过调用方上下文绑定相关生命周期:

ownerCtx.effect(...)

公共创建入口传递的不只是构造参数,也包括资源所有权。

这也是 getTraceable() 和 Reflect.apply() 出现在委托代码里的原因:它们服务于 Cordis 的调用追踪和上下文绑定,不只是为了实现一次普通函数转发。

十、别把“可以替换”理解成“随便实现”

这套设计提供了替换默认驱动的入口,但替换并不免费。

一个新实现不能只是补齐几个同名方法:

followup()
cancel()
whenIdle()

就认为工作结束了。

它还需要满足这些方法背后的语义:

  • 输入何时被接纳。
  • 取消影响什么。
  • whenIdle() 等待什么。
  • Session 如何记录。
  • 初始化何时完成。
  • 事件以什么顺序发出。
  • 销毁时如何等待在途工作结束。

TypeScript 的 implements 可以检查类型结构,但不会自动验证上述行为。

因此,更准确的说法是:

公共接口提供了可替换实现的明确接入点,但行为兼容仍然需要实现、测试和生命周期约束共同保证。

如果只是更换模型供应商,或者给模型增加工具,也不应该因此重写 Agent 驱动。项目为这些变化提供了各自的适配器和工具扩展机制。

替换整个驱动,适合默认运行方式确实不足以满足需求的场景。

十一、理解它的关键:区分两种方向

最后,用两张图总结。

运行时调用方向:

调用方
  ↓
公共注册表
  ↓
已注册工厂
  ↓
默认 AgentLoop
  ↓
具体 ReactLoopAgent

源码依赖方向:

调用方 ─────────→ 公共 Agent 包
                       ↑
                       │
默认实现包 ─────────────┘

运行时,公共入口会调用具体实现。

但在源码依赖上,公共层不需要导入默认实现。

接口与注册机制把这两件事连接起来。

理解这一点,也就理解了这个设计为什么不只是“多包了一层函数”。

结语

Agent 框架最容易展示的是模型调用和工具循环。

但当它成为长期运行、支持多个入口和插件的系统,真正重要的问题会变成:

  • 哪一层拥有行为约定?
  • 哪一层拥有实现细节?
  • 谁负责创建?
  • 谁负责清理?
  • 谁可以依赖哪些能力?

DeepSeek Harness 通过 Agent、AgentFactory、AgentRegistry 和默认 AgentLoop 的拆分,把这些责任分开。

它的核心价值不是多了几个接口,也不只是为未来更换算法预留空间,而是:

让使用 Agent 的代码不必理解 Agent 的内部实现,同时让构造、初始化和生命周期责任有明确的归属。

当一个 Agent 系统不再只是演示脚本,这种责任划分,往往比循环本身更值得设计。