写一个能调用工具的 Agent,最初似乎并不复杂:
typescript
// 示意代码
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 实现,负责运行循环 |
其中最重要的对应关系是:
typescript
AgentLoop implements AgentFactory
ReactLoopAgent implements Agent
AgentLoop 不是那个直接实现 Agent 接口的类。
它更接近一个负责创建、恢复和管理默认 Agent 生命周期的服务。真正接收输入、推进状态、运行步骤的是 ReactLoopAgent。
如果把这几个角色混在一起,后面的代码会显得比实际复杂。
二、从调用方开始:创建者需要知道多少东西?
先看项目中的 ACP 会话模块。
你不需要了解 ACP 协议,只需要把它当成一个"需要创建 Agent 的业务接入层"。
它的创建逻辑如下:
typescript
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,
)
}
这段代码表达了四个需求:
- 创建一个指定身份的会话及 Agent。
- 设置工作目录和模型选项。
- 在正式发布前安装这个会话需要的能力。
- 返回一个可以使用和清理的对象。
注意,这里没有:
typescript
new ReactLoopAgent(...)
也没有模型请求循环,更没有手动构造内部状态机。
调用方说的是:
我需要一个符合公共约定的 Agent。
而不是:
我要亲自组装默认循环实现。
这是整套设计的起点。
三、公共创建入口并不直接构造对象
继续追踪 ctx.agents.create()。
它对应 AgentRegistry 的方法:
typescript
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 的服务追踪机制。
把主要逻辑压缩一下,它相当于:
typescript
// 示意代码
async function create(options) {
const factory = requireRegisteredFactory()
return factory.createAgent(
callerContext,
options,
)
}
这里最重要的事实是:
注册表没有导入具体类并直接执行 new,而是委托给已注册的工厂。
如果注册表内部直接写:
typescript
// 反例示意
async function create(options) {
return new ReactLoopAgent(...)
}
那么即使调用方使用了统一的 create() 入口,公共层仍然与默认实现绑定。
想换实现,还是得修改注册表。
现在,注册表只要求"有一个能够创建 Agent 的工厂",不需要知道这个工厂内部怎样完成工作。
四、工厂接口为什么属于公共包?
工厂约定的核心结构很小:
typescript
export interface AgentFactory {
createAgent(
ownerCtx: Context,
options: CreateAgentOptions,
): Promise<AgentHandle>
resume(
ownerCtx: Context,
options: ResumeAgentOptions,
): Promise<AgentHandle>
}
它定义两个操作:
- 从创建选项生成一个新的 Agent。
- 从持久化会话恢复一个 Agent。
两者都返回 AgentHandle。
这里比接口内容更值得关注的是:接口由公共 Agent 包定义,而不是由默认 AgentLoop 实现包定义。
于是,源码依赖方向变成:
text
ACP 等调用方 ──────────→ Agent 公共包
↑
│
默认 AgentLoop 实现包 ────────┘
调用方依赖公共约定,默认实现也依赖公共约定。
公共层不必反过来导入默认实现。
这就是依赖倒置在这里的具体表现:不是抽象地"多写一个 interface",而是明确了由谁拥有双方共同遵守的接口。
当然,TypeScript 接口在运行时不会自动完成连接。真正连接两边的是注册动作。
五、默认实现如何接入?
默认驱动服务明确声明自己实现工厂:
typescript
export class AgentLoop
extends Service
implements AgentFactory {
static inject = [
'agents',
'sessions',
'llm',
'tools',
'systemPrompt',
'sessionProjections',
]
// ...
}
在构造过程中,它将自身注册到 Agent 服务:
typescript
ctx.effect(
() => ctx.agents.setFactory(this),
'agentLoop.setFactory()',
)
至此,关系变成:
text
加载 AgentLoop 插件
↓
将 AgentLoop 实例注册为工厂
↓
AgentRegistry 持有工厂
↓
后续 create() 调用转发给该工厂
注册逻辑还包含两个重要约束。
第一,已经存在工厂时,拒绝注册第二个:
typescript
if (this.factory !== undefined) {
throw new Error(
'an agent factory is already registered',
)
}
第二,注册附带清理动作:
typescript
this.factory = { target }
return () => {
this.factory = undefined
}
这说明工厂注册不是一个永久的全局赋值,而是受到插件生命周期管理的贡献。
也需要注意其适用范围:
一个注册表的工厂槽位同一时间只能有一个工厂。这不是按请求选择多个驱动的路由器,也不意味着可以无缝替换正在运行的 Agent 对象。
可替换的接入点与运行中无损迁移,是不同的能力。
六、真正的 new 被放在哪里?
注册表委托给工厂后,会进入:
typescript
AgentLoop.createAgent(ownerCtx, options)
默认实现准备 Session、处理存储资源,再进入创建和发布流程。
实际构造对象的关键代码是:
typescript
const created = machine = new ReactLoopAgent(
loopCtx,
id,
options,
session,
)
现在,调用链完整了:
text
ACP 会话模块
│
▼
ctx.agents.create(options)
│
▼
AgentRegistry.create(options)
│
▼
已注册工厂的 createAgent(ownerCtx, options)
│
▼
AgentLoop 的创建与发布流程
│
▼
new ReactLoopAgent(...)
new 没有消失。
它只是被放到了真正拥有构造知识的地方:默认实现内部。
这很重要,因为创建 Agent 不只是调用构造函数,还涉及:
- 准备 Session。
- 获取持久化写入资源。
- 创建局部作用域。
- 运行调用方提供的
setup。 - 注册并发布对象。
- 等待初始化监听器。
- 失败时回滚已经准备的资源。
调用方不需要重复理解这些步骤,只需通过公共入口表达需求。
七、返回的是公共能力,不是内部状态机
默认对象声明如下:
typescript
export class ReactLoopAgent implements Agent {
readonly inbox: ReactLoopInbox
private phase: Phase
private activityDone: Promise<void> =
Promise.resolve()
// ...
}
其中,phase 是实现细节。
调用方使用的公共 Agent 接口,则提供输入、取消、状态和等待等能力。
把相关声明合并并裁剪后,可以理解为:
typescript
// 公共接口的局部示意,非完整定义
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>
}
公共接口描述调用方可以依赖的行为。
默认实现负责兑现这些行为。例如:
typescript
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?
创建接口返回:
typescript
export interface AgentHandle {
agent: Agent
dispose(): Promise<void>
}
这里有两层信息。
第一,agent 的公开类型是 Agent,不是 ReactLoopAgent。
真实对象可以使用默认实现,但调用方通过公共接口使用它。
第二,销毁能力放在 Handle 上。
这区分了两件事:
text
持有 Agent
→ 可以与它交互
持有 AgentHandle
→ 还拥有创建方的销毁入口
观察一个 Agent 的模块,不一定是拥有它生命周期的模块。
例如:
- UI 可能只是展示运行状态。
- 插件可能只是注入上下文。
- 编排器可能才是创建和回收它的所有者。
把所有权显式表达出来,比让所有拿到 Agent 的代码都能随意销毁对象,更容易维护。
需要补充的是,默认工厂提供者也承担结构性的生命周期责任:它卸载时,需要停止并等待自己创建的相关对象完成清理,而不是只清空一个引用。
九、这套设计的实际收益是什么?
"以后可以换实现"当然是一项收益,但不是最直接的一项。
真正的收益已经出现在当前代码中。
1. 缩小内部重构的影响范围
假设默认驱动需要修改:
- 输入队列结构。
- 状态机表示。
- 调度方式。
- 请求重试的内部组织。
只要继续满足公共 Agent 行为,ACP 这类调用方就不需要跟着修改内部细节。
这不意味着公共接口永远不会变,而是把"实现重构"和"公共行为变更"区分开。
2. 集中管理复杂初始化
创建者提供配置及 setup,默认工厂负责完整的构造事务。
否则,不同入口可能各自复制:
text
创建 Session
创建 Agent
装工具
注册对象
发布通知
处理失败
这些流程一旦分散,很容易出现某个入口遗漏清理、提前发布对象,或者在失败时留下半初始化状态。
统一创建入口减少了这种差异。
3. 可以独立测试公共创建层
仓库中已有测试先加载 AgentRegistry,但不安装默认 AgentLoop:
typescript
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await expect(
ctx.agents.create({
sessionId: SessionId('s'),
}),
).rejects.toThrow(/no agent factory/)
然后注册测试工厂:
typescript
const { factory, calls } = stubFactory()
ctx.agents.setFactory(factory)
这样就能测试:
- 没有工厂时是否明确失败。
- 创建和恢复是否正确委托。
- 选项是否正确传递。
- 调用方的生命周期上下文是否被保留。
这些测试不需要启动模型请求循环。
这说明工厂接口不仅服务于替换生产实现,也让公共层的责任可以被单独验证。
4. 生命周期所有权能统一传递
再回头看这个参数:
typescript
createAgent(ownerCtx, options)
为什么需要显式传递 ownerCtx?
因为"工厂由谁提供"和"这个 Agent 由谁创建并拥有"不是同一个问题。
假设全局加载了 AgentLoop,而一个临时业务插件创建了 Agent。
业务插件卸载后,这个 Agent 不应该因为全局工厂仍然存在,就变成无人负责的后台工作。
默认实现通过调用方上下文绑定相关生命周期:
typescript
ownerCtx.effect(...)
公共创建入口传递的不只是构造参数,也包括资源所有权。
这也是 getTraceable() 和 Reflect.apply() 出现在委托代码里的原因:它们服务于 Cordis 的调用追踪和上下文绑定,不只是为了实现一次普通函数转发。
十、别把"可以替换"理解成"随便实现"
这套设计提供了替换默认驱动的入口,但替换并不免费。
一个新实现不能只是补齐几个同名方法:
typescript
followup()
cancel()
whenIdle()
就认为工作结束了。
它还需要满足这些方法背后的语义:
- 输入何时被接纳。
- 取消影响什么。
whenIdle()等待什么。- Session 如何记录。
- 初始化何时完成。
- 事件以什么顺序发出。
- 销毁时如何等待在途工作结束。
TypeScript 的 implements 可以检查类型结构,但不会自动验证上述行为。
因此,更准确的说法是:
公共接口提供了可替换实现的明确接入点,但行为兼容仍然需要实现、测试和生命周期约束共同保证。
如果只是更换模型供应商,或者给模型增加工具,也不应该因此重写 Agent 驱动。项目为这些变化提供了各自的适配器和工具扩展机制。
替换整个驱动,适合默认运行方式确实不足以满足需求的场景。
十一、理解它的关键:区分两种方向
最后,用两张图总结。
运行时调用方向:
text
调用方
↓
公共注册表
↓
已注册工厂
↓
默认 AgentLoop
↓
具体 ReactLoopAgent
源码依赖方向:
text
调用方 ─────────→ 公共 Agent 包
↑
│
默认实现包 ─────────────┘
运行时,公共入口会调用具体实现。
但在源码依赖上,公共层不需要导入默认实现。
接口与注册机制把这两件事连接起来。
理解这一点,也就理解了这个设计为什么不只是"多包了一层函数"。
结语
Agent 框架最容易展示的是模型调用和工具循环。
但当它成为长期运行、支持多个入口和插件的系统,真正重要的问题会变成:
- 哪一层拥有行为约定?
- 哪一层拥有实现细节?
- 谁负责创建?
- 谁负责清理?
- 谁可以依赖哪些能力?
DeepSeek Harness 通过 Agent、AgentFactory、AgentRegistry 和默认 AgentLoop 的拆分,把这些责任分开。
它的核心价值不是多了几个接口,也不只是为未来更换算法预留空间,而是:
让使用 Agent 的代码不必理解 Agent 的内部实现,同时让构造、初始化和生命周期责任有明确的归属。
当一个 Agent 系统不再只是演示脚本,这种责任划分,往往比循环本身更值得设计。