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 行的过程。