atelier 源码拆解 · 一

把模型变成 Agent,中间差着一层

atelier 是一个纯仓颉实现的 Agent Harness——按它自己的说法,
「把模型变成 Agent 的那层脚手架」。三个成员合计 45860 行(不含测试)、
2058 个测试用例。

本文讲它要解决什么问题、怎么解决,以及为此付出的取舍。

它的底座是 badger-cj (存储)与
mcp-cj / json-rpc (协议),
下游有世界树、星痕等产品;忆时塔
是它在记忆方向的一个应用。

一、先说清楚「Harness」是什么

这个词不常用,容易和框架、SDK、运行时混在一起。区别在最下:

干什么 例子
API 客户端 把请求发出去、把响应收回来 各家 SDK 的 chat()
编排框架 按你的图去调多个步骤 LangGraph、工作流引擎
Agent Harness 把一个模型变成一个能自主干活的 Agent Claude Code、Codex CLI、atelier

关键差别在最后一行:harness 不替你决定做什么,但它提供「自主干活」所需的全部机制。

一个裸模型做不到这些事,而一个 Agent 必须做:

  • 多轮自我驱动(想 → 调工具 → 看结果 → 再想)
  • 上下文管理(对话越来越长,窗口装不下怎么办)
  • 工具注册与调用(模型怎么知道有哪些工具、怎么调、结果怎么回灌)
  • 权限判定(模型想执行一条命令,该不该放行)
  • 会话持久化(这次聊的,下次要能接着聊)
  • 多协议接入(同一个 Agent,被不同宿主拉起来)

这六件事就是 atelier 的全部内容。 下面一件件看。

二、问题一:推理循环

Agent 的本质是一个循环:

想 → 调工具 → 观察结果 → 再想
   → …… → 给答案

看起来简单,但工程上有一堆细节:

  • 什么时候该停? 模型可能一直调工具不停
  • 工具批次怎么执行? 一批 5 个工具,串行还是并行
  • 中途被打断怎么办? 用户想插话
  • 预算耗尽怎么办? 工具调用次数有上限

atelier 的循环是 AgentRuntime,主循环在 1.1.0 之后从 548 行整理到 270 行。它抽出七个阶段:

阶段 职责
openTurn 回合开场、处理插话、归档
compactBeforeRequest 请求前的上下文压缩
invokeProvider 调模型 + 超窗恢复
executeToolBatch 批量执行工具、回写结果
admitToolCall 工具准入判定(三分支)
admitAfterHooks 钩子裁决
admitByAsking 需要人工确认时发起提问

这个拆分本身有个教训:最初想按「阶段类」拆文件,撞墙两次;
后来先理清控制流再拆,才对。拆分的前提是想清楚顺序,不是想清楚分几块。

插话:一个容易做错的语义

用户在一轮对话进行中补充一句,怎么处理?三个选项:

方案 行为 问题
A. 打断当前工具 立刻停下 工具可能执行到一半,留下半写状态
B. 排队,下一次模型调用可见 当前工具跑完再带上 选了这个
C. 丢弃 报错「上一轮仍在进行中」 用户体验差

atelier 选 B,并且加了一个细节:插入时带标注。

因为模型默认把「最后一条用户消息」当最新指令,裸插一句「还有 X 吗」会被理解成推翻了原任务。加上标注(类似 [用户中途补充])才不会有歧义。

三、问题二:上下文管理

对话越聊越长,窗口装不下。atelier 的做法是一套阈值 + 四层应对。

3.1 阈值怎么算

触发阈值 = min(窗口 × 0.8,
              窗口 − 输出预留
              − headroom)
参数 默认 作用
thresholdRatio 0.8 阈值占窗口的比例
headroomTokens 65536 输出预留之外的额外余量(估算误差、图片、工具声明)
retainRatio 0.16 逐字保留的近期历史占比
maxOverflowRetries 1 已溢出后的重试次数

取值来自 dsh 的经验口径,不是拍的。

3.2 四层应对

按「侵入性从小到大」排:

层 做法 什么时候用
1. 工具结果修剪 超长工具输出留头 4096 码点 + 标记 + 尾 1024 每次请求前
2. 摘要压缩 头部历史压成一条 CompactionMessage 超过阈值时
3. 超窗恢复 已确定溢出 → 强制压缩后重试 模型报超窗时
4. 手动 /compact 用户主动触发 想省 token 或清理混乱上下文

几个实现细节值得说:

摘要复用主对话前缀。 摘要不是独立请求,而是把主对话的前缀拿来做,这样能吃 provider 的缓存(同一前缀第二次调用便宜)。

用量优先,估算兜底。 优先用 provider 返回的 usage;没有就用本地估算。

压缩可替换。 compactor 是 HarnessSpec 的一个字段,默认 BasicCompactor(摘要 + 切点替换),想换策略就注入自己的实现——不把「压不压、怎么压」写死在内核里。

四、问题三:工具系统

工具是 Agent 的手脚。atelier 的工具层有三个要点。

4.1 三层来源

来源 怎么来
内置工具 框架自带(读文件、执行命令等)
MCP 工具 通过 ystyle::mcp 接入任意 MCP 服务器(stdio / Streamable HTTP)
Agent-as-Tool 把另一个 Agent 当工具用(子 Agent 委派)

前两种是常规做法,第三种比较特别:子 Agent 有独立的上下文,父 Agent 调它就像调一个函数,只拿到结果不共享上下文。

4.2 工具注册表分两层

HarnessSpec {
    // 根表:全局可见
    tools: ToolRegistry
}

运行时还会叠加一层 ToolRegistry.derived——run 作用域的工具落在这里,run 结束即弃。

这个设计解决了一个真实问题:如果 run 级工具写进根表,两个并发会话会互相看见对方的工具,可能按别人的工作目录过信任闸门。

4.3 工具声明影响缓存

一个容易忽略的点:工具列表的顺序会毁掉提示词前缀缓存。

如果每次请求工具声明的顺序都不同,provider 侧的前缀缓存就失效了。

顺序为什么会抖:可见工具列表每轮重算(MCP 冷却要随回合推进),而「按 prefix_* 通配匹配」那条分支遍历的是工具注册表——注册表底层是 HashMap,迭代顺序取决于插入历史。运行中按需注册 MCP 工具、或不同会话先加载了不同的工具集,都会改变这个历史。

于是同一批工具可能排出不同顺序。而前缀缓存按「前缀单元完整匹配」计价,只要从第一个工具起就不同,整段都按未命中算。

修法是在解析出口统一排一次(插入排序,工具数量很小):

// 按工具名就地稳定排序。
// 存在的理由只有一条:字节稳定性——
// 工具声明与 PTC SDK 段都是提示词前缀
// 的一部分,批次相同但顺序不同的前缀
// 等于两次不同的前缀。
func sortToolsByName(
    tools: ArrayList<Tool>)

一行调用,直接同时影响成本和延迟。

五、问题四:信任与权限

模型想执行一条命令,该不该放行?这是 Agent 最危险的地方。

5.1 三态判定

public enum TrustState {
    | Undecided    // 没决定过
    | Trusted      // 信任这个目录
    | Untrusted    // 明确不信任
}

判定时按优先级查三层:

per-session 覆盖(含父目录)
  → sessionTrusts(无会话上下文的回退)
    → states(目录级持久信任)

5.2 信任是「目录级」的

不是按命令、也不是按工具,而是按目录。理由很实际:

你信任一个项目目录,意味着「在这个目录里跑工具是安全的」——
这符合人的直觉,也比逐条命令审批省事。

5.3 Undecided 时发结构化审批

关键设计:Undecided 不是「拒绝」,而是发起提问。

模型要执行 rm -rf build/
  → 目录未决定 → 发审批请求
  → 用户选:允许本次 / 总是允许 / 拒绝

而且这个提问是统一的提问原语:审批、单选、多选、表单都走同一个 UserRequest → UserAnswer 通道。四个宿主(TUI / ACP / AG-UI / HTTP)各自接线,一行核心代码不用改。

5.4 闸门可替换

type TrustGate =
    (Tool, String, String)
    -> TrustDecision

单方法、无状态 —— 所以用函数类型而不是 interface。宿主想换策略,注入一个函数就行,不必写类。

六、问题五:会话

6.1 树,不是线性

会话不是一条消息列表,而是一棵树:

public struct Entry {
    let id: String
    // 关键:指向父节点
    let parentId: Option<String>
    let timestamp: Int64
    let message: Message
}

parentId 让会话可以 fork / clone:

        user: 实现一个功能
              │
      ┌───────┴───────┐
   assistant A     assistant B
      │                 │
   (分支 1)         (分支 2)

同一个起点,两条分支各自演进。想回到某个节点重新试另一条路,不需要重放历史。

6.2 消息类型

类型 说明
UserMessage 用户输入
AssistantMessage 模型输出(含工具调用)
ToolResultMessage 工具结果
CompactionMessage 压缩产物(摘要)
ArchiveMessage 归档标记

后两种是「非对话消息」——它们不占据对话语义位置,但必须在历史里可寻址。

6.3 存储可换

store: SessionStore 是装配字段。默认 MemorySessionStore,也可以换成基于 storm-cj 的持久化实现(JSONL / 表存储)。

七、问题六:多协议接入

同一个 Agent,要被不同宿主拉起来。atelier 内置了三种协议适配:

协议 用途 方向
ACP Agent Client Protocol,被 Zed / opencode 拉起 Agent 侧、Client 侧
A2A Agent2Agent,Agent 之间互相调用 双向(暴露自己 / 接入远端)
AG-UI 前端 UI 协议 事件映射

这不是「顺手支持」,而是同一套事件的不同投影:

        内核事件(AgentEvent)
                │
     ┌──────────┼──────────┐
     ▼          ▼          ▼
   ACP        A2A       AG-UI
  (stdio)   (JSON-RPC)  (SSE)

内核只产生事实,各协议适配层决定怎么投影。这样做的好处是:新增一个协议不用动内核。

八、装配面:17 个可注入点

这是 atelier 最核心的设计。整个框架的能力都收在一个 HarnessSpec 里:

类别 字段
工具 tools
模型 providers models middlewares
会话 store
信任 trust trustGate
扩展 skills agents hooks contributors
外部 mcp
交互 ask
内核策略 compactor loopFactory statusRegistry eventBus

每一项都有默认值,也都可以替换。 装一个默认 spec 就能跑:

let h = Harness.open()

想定制就传自己的:

let spec = HarnessSpec.default()
    .withCompactor(MyCompactor())
    .withTrustGate(myGate)
let h = Harness.open(spec)

这个设计的价值在「内核策略也可替换」——压缩策略、循环实现、状态持有者都能换,意味着 atelier 不是「一套写死的实现」,而是「一组约定的接口 + 一份默认实现」。

九、取舍:它不做的事

诚实说清楚边界:

不做 原因
不做可视化编排 它是 harness,不是工作流引擎——用代码或 TOML 定义 Agent
不做模型训练 / 微调 只消费 provider
不做多机分布式 单进程定位
不做提示词市场 Skills 是机制,内容由用户提供

还有一个更微妙的:

它不假装自己是「框架」。 README 里的措辞是「成品工坊而非编排框架」——区别在于:框架要求你按它的抽象去描述你的应用,工坊只给你可挂载的零件。

十、规模与现状

项 数值
全仓(三个成员) 45860 行(不含测试)
核心库 atelier 16270 行 / 89 个文件
服务库 server 3276 行 / 9 个文件
终端产品 cli 3396 行 / 11 个文件
测试 2058 个用例(162 个测试文件)
模块 15 个包(agent 5550 行、tool 2386 行、context 1663 行……)
成员 3 个(核心库 atelier + 服务库 server + 终端产品 cli)
版本 0.1.x,中心仓可装

配套的下游产品有世界树(插件化宿主)、星痕(状态看板)、Lumine 等——它们是真在用的,这也是 atelier 迭代快的原因:需求来自真实产品,不是想象。

十一、小结

回到开头那六个问题:

问题 atelier 的答案
推理循环 AgentRuntime,七阶段拆分、插话排队不打断
上下文管理 阈值 + 四层应对(修剪/摘要/恢复/手动)
工具系统 三层来源、根表 + run 派生表
信任 目录级三态 + 结构化审批 + 可替换闸门
会话 parentId 树、可 fork、存储可换
多协议 内核出事实,适配层各自投影

而把它们串起来的是那个 17 个注入点的装配面——「哪些能换」就是这个框架的边界,「默认值是什么」就是它的主张。

下一篇会讲它踩过的坑:包括「加一层抽象往往是错的直觉」、
「flaky 比明确失败更危险」,以及一次把 548 行循环拆成 270 行的过程。