本文是项目级设计原则的唯一出处(canonical)。 各设计文档回指本文,不重述——原则散在个案里的下场,就是下一份文档 再把它重新推导一遍(这正是 §1 被提取出来的原因)。
怎么用:提新抽象、审 PR、写设计文档时援引(「按 §1 四问判据,这条落在 假想列,不立项」),而不是重新论证一遍。原则被违反时,改的是代码或方案, 不是原则——除非有人能推翻原则本身,那需要一次显式修订并记在这里。
收录门槛:够得上本文的是硬约束——可援引、有判据、能判定违反。 「尽量」「最好」不进这里,那些属于各文档的局部取舍。
| # | 原则 | 一句话 | 出处 |
|---|---|---|---|
| 1 | 无真实需求不建 | 抽象由真实需求触发,不由对齐别人 / 对称性 / 「以后可能要」触发(改名与分层不受管,见 §1.5) | advisor-alignment-design.md §1.3「立项判据」→ tool-calling-manager-design.md §0.5 |
| 2 | 框架无关 | agent 框架是一个库,不是 web 应用;core 不依赖任何 web 框架 | streaming-async-design.md §0.5 |
| 3 | 一个 ChatClient 绑定一个 TCM,不跨边界 | 这个绑定就是执行边界:边界内必须一致,边界外不得流通 | tool-calling-manager-design.md §4.3.1 → 2026-07-16 用户拍板提为项目级 |
抽象要由真实需求触发,不由对齐别人、对称性、或「以后可能要」触发。 用户在自己代码里几行就能等价做到的事,框架不长字段、不开 seam、不加协议。
出处:本是 advisor-alignment-design.md §1.3 的「立项判据」(「出现真实需求时
再抽」),因在 tool-calling-manager-design.md 里被反复重新发现(毙 deftool :backend、毙 PR2/PR3、拆掉一个防御性保护)而提为硬约束。
deftool 多一个 :backend,就永远多一个
要兼容、要文档、要测试、要在每次重构里绕开的东西;用户函数体里的 5 行 HTTP 包装
删了就没了。成本不对称:建的代价是永久的,不建的代价是几行。:backend :http 只能表达一种
POST 形状,真实 HTTP 工具要自定义 header / 非 JSON body 时立刻退回手写)。ToolCallback 用接口承担的 transport 多态,在这里由函数体本身承担。怎么分辨真实需求与假想需求——每个人都觉得自己的需求是真的,所以要逐条过:
| 问题 | 真实需求 | 假想需求 |
|---|---|---|
| 现在有人要用吗? | 有具体场景、具体调用方 | 「以后可能」「为了完整」 |
| 不建的话用户怎么办? | 做不到,或代价高到不可接受 | 自己写几行就等价 |
| 换来的是什么? | 能力(不建就没有的事) | 只是「更声明式 / 更对称 / 更像 Spring」(例外见 §1.5) |
| 触发条件写得出来吗? | 写得出具体场景 | 写不出,只能说「万一」 |
| 情形 | 约束 |
|---|---|
| 提新抽象 | 先回答 §1.2 四问。四问里有一问落在「假想」列,不立项;把理由写进否决记录,供以后重启时对照 |
| 已建的抽象,论据倒了一条 | 重新称剩下的论据有多重,不假定它跟着变轻,也不假定它还够重 |
| 框架的「防御性机器」 | 同样适用——为防一个没人会踩的坑而加的保护,也是无真实需求的建造 |
| 已否决的方案 | 保留否决记录,但重启时须重新设计,不得直接捡回旧方案 |
| 能立不变量的,不建机器 | 与其造机器去容忍某个用法被违反,不如把不变量写清楚(该不变量已提为 §3) |
判例都出自 tool-calling-manager-design.md,含一次自打脸:
| 案例 | 判定 | 依据 |
|---|---|---|
deftool :backend(§5) | ❌ 否决 | 用户函数体里写 (http/post ...) 5-10 行等价;换来的只是「更声明式」,且表达力反而更弱 |
| PR2 / PR3(§8) | ❌ 否决 | 同上。从「⏸️ 搁置待需求」改判否决——有 HTTP/MCP 需求也不做,因为那个需求手写就解决了,不构成触发条件 |
*active-pools* 自锁保护(§4.3.1) | ❌ 拆除 | 用「子 agent 共用有界池会死锁」论证它,但子 agent 根本不共享引擎(无继承路径)。主场景是编的;剩下的同线程情形没人碰得到 |
ToolCallingManager 自己(§2) | ⚠️ 险些踩中 | 立项两条腿:多 transport(假想,已否决)+ 隔离与线程模型可换(真实:react.clj 的 VT executor 是进程全局 def,无舱壁 / 无限流 / 无可关停边界)。假想那条腿当时看起来比真实那条更有说服力——这正是本原则要防的 |
最要紧的一条:本原则对自己也适用。上表第三行就是作者一边用它毙掉
:backend、一边转头为假想需求建了一套动态变量机器。标准不能只对别人用。
§1.2 的四问表把「更像 Spring」整条划进假想列。这条表述过宽,2026-08-25 的
kernel→chat-client / service→chat-model 改名与随后的分层重构当场撞上它——
按字面读,那两轮工作全部违反 §1。用户拍板推翻该读法,本节即那次修订的记录。
修订后的边界:
| 对标什么 | 本原则管不管 | 理由 |
|---|---|---|
| 给已有的东西改名 / 换分层位置 | ❌ 不管 | 不新增 API 面。Kernel→ChatClient 之后,要兼容、要文档、要测试的东西还是同一个,只是叫法换了。§1.1 的「成本不对称」论据在这里不成立——它整个建立在「多一个字段 = 永久多一份维护」上 |
| 为了对标而新建抽象 / 长字段 / 开 seam | ✅ 照管 | deftool :backend 那类。四问照过,不因为「Spring 有」就放行 |
为什么改名不算「无真实需求」:需求是认知成本,而它是真实的、有具体
承受者的——用 Spring AI 或 beamai 的人读这份代码时,Kernel / service 这两个
名字与他脑子里的分层对不上,每次都要重新映射一遍。§1.2 的四问里,「不建的话
用户怎么办」在这里的答案是「每个读者自己在脑子里维护一张对照表」,那不是
「自己写几行就等价」。
判据一句话:本原则管「该不该有这个东西」,不管「这个东西该叫什么、放哪」。
一次当场的复盘(同日,用户拍板推翻了作者的判断):ChatClient 的四个工具
字段(tools / tool-vars / inline-handlers / tool-meta)该不该收成一个
ToolRegistry record 字段。作者拿四问答了「不做」——理由是「换来的只是对称性、
(:tools cc) 反而更绕、是 v0.3 内第三次改 record arity」。用户判做,作者的
三条理由逐条不成立:
| 作者的理由 | 为什么不成立 |
|---|---|
| 「换来的只是对称性」 | 换来的是不变量:四者总是一起产生、一起使用、一起被子 agent 整体替换。收成一个值之后「一个 ChatClient 有一个工具注册表」在类型上成立,而不是靠约定——这正是 §3 立不变量那条的同款做法 |
「(:tools cc) 更绕」 | 平铺四个字段才是让调用方替你记着「这四个要一起换」。收进去之后多打一个 tool-schemas,换来的是漏换一个字段的可能性归零 |
| 「第三次改 arity」 | 改动次数不是判据。它衡量的是作者的麻烦,不是设计的对错;照这条推下去,先落地的错形状会因为「已经改过两次了」而永久固化 |
教训:§1 的四问是用来问新建的(该不该有这个东西),拿它去否决一次 收拢(已有的东西该怎么组织)是用错了工具——那正是本节 §1.5 划的那条边界, 作者在写下这条边界的同一次工作里又踩了一次。标准不能只对别人用(§1.4 末)。
agent 框架是一个库,不是 web 应用。core 永远不依赖、不捆绑任何 web 框架 (Luminus / Ring / Undertow / Jetty / Aleph / http-kit-server 一律不行)。
出处:streaming-async-design.md §0.5。
on-token / on-complete / on-error + cancel 是最小的
push 契约,任何 sink 都能接(http-kit send! / Undertow
ServerSentEventConnection.send / Aleph manifold stream / Jetty async body /
终端 print / core.async channel…)。框架特定的东西全在适配那一层,core 一无所知。java.net.http + 一个回调契约不会。
绑死某框架 = 跟着它的生命周期走。| 层 | 约束 |
|---|---|
| 库本体(clj-agent-core / clj-agent-client / clj-agent-provider / clj-http 的 src) | 零 web 框架依赖。只暴露回调原语(chat-stream + on-token/cancel)。传输用 JDK java.net.http(非 web 框架),收在零依赖的 clj-http 一处(designs/http-module.md) |
| 集成代码 | 只放 examples/(文档级、可跑);不进 src、不进核心 :deps。web 依赖只在该 example 的 alias 里 |
| 不挑框架站队 | 不做「我们集成 Luminus」这种定位。要给示例就覆盖多个(http-kit / Undertow-SSE / Aleph)或讲通用模式(「拿到 token 往你的 sink 写」) |
| 若真要发适配器 | 单独的可选模块 + web 依赖标 provided/optional,绝不污染 core。已落地:clj-agent-server(只认 Ring SPEC)+ clj-agent-server-jetty(缺省 adapter),见 designs/server-module.md |
已满足:chat-stream 只认回调原语、出站 HTTP 全部经 clj-http(JDK java.net.http)、
deps.edn 无任何 Ring/Luminus/Undertow/web-server 依赖。
本原则是「守住现状」,不是「待改造」。
一个 ChatClient 恰好绑定一个 ToolCallingManager,且这个绑定就是执行边界。 边界内必须一致——工具看到的东西不得因批次大小 / 引擎选型 / filter 挂载而变; 边界外不得流通——父 ChatClient 的引擎与环境状态不自动流入子 Agent。
出处:tool-calling-manager-design.md §4.3.1(拆除 *active-pools* 自锁保护时
立的不变量)。原先只是 §1.3 表格里的一个括号举例,2026-07-16 用户拍板提为
项目级硬约束——理由见 §3.4:它被埋在别的原则里,导致真要用时没人想得起来。
ThreadPoolToolCallingManager 的舱壁之所以是舱壁,正因为没有别人
能挤进这个池。让引擎跨边界流动,等于宣布舱壁不隔离。react.clj 明写「引擎只决定『怎么把这批
跑完』,不决定『跑的是什么』」。若换个引擎、或 LLM 多发一个 tool-call,工具
看到的东西就变了,这句话即是空话——用户无法在不改变语义的前提下选型。subagent-config。*active-pools* 去检测「子 agent 共用池会不会死锁」,不如立下不变量说它们本就
不共用。不变量是零成本的,机器是永久成本的。管的是执行环境(ambient state:动态绑定、线程身份、调度可观察到的一切), 不是行为本身。先把这条划清楚,否则本原则会把整个 filter 体系判成违反 (见 3.2.1)。
| 方向 | 判据 | 违反长什么样 |
|---|---|---|
| 边界内一致 | 同一 ChatClient/TCM + 同一组 filter 下,工具的执行环境不得因调度差异(批次大小、引擎选型、内联 vs executor)而变 | 同一个工具,LLM 发 1 个 tool-call 时读到 :acme、发 2 个时读到 :none |
| 边界外不流通 | 父 ChatClient 的 TCM / 动态绑定 / ambient 状态不得自动流入子 Agent | 父进程的 binding 悄悄在子 Agent 的工具里生效;父 TCM 的池被子 Agent 占用 |
要点:这两条不是互相冲突的,它们是同一条线的两侧。判断任何「某状态该不该 到达某处」时,先问:**中间隔着 ChatClient/TCM 边界吗?**没隔着 → 必须一致;隔着 → 必须显式,否则不给。
filter 不在「边界内一致」的禁令里。挂 approval-filter 决定工具跑不跑、挂
timeout-filter 决定超不超时——改变行为正是 filter 存在的理由,也是用户
显式声明的意图。把「filter 挂载」写进禁令等于禁掉整个 filter 体系。
真正要禁的是副作用式的环境扰动:filter 改变了它没声明要改的东西。判据:
| 例 | 判定 | |
|---|---|---|
| filter 改它声明要改的 | timeout-filter 让未声明的工具有了缺省超时;approval-filter 拒绝执行 | ✅ 本职 |
| filter 顺手改了执行环境 | timeout-filter 曾把工具搬去 send-off 平台线程,顺带弄丢 binding | ❌ 违反 |
判据一句话:挂上一个 filter,工具该看到它声明的效果,不该发现自己的
binding 没了、线程模型变了。
这条修订的由来(2026-07-16 code review 自查):3.2 初稿把「filter 挂载」 与批次大小、引擎选型并列写进禁令,字面上会把
timeout-filter与approval-filter双双判为违反——而本原则同期的落地工作恰恰依赖 「挂了 timeout-filter 才有缺省超时」。原则要能判定违反才算硬约束:会把合法 设计判成违反的表述,援引者只能靠猜哪条算数,等于没有判据。
| 情形 | 约束 |
|---|---|
| ChatClient ↔ TCM | 1:1。TCM 是 ChatClient record 的字段,经 build-chat-client :tool-manager 注入;一个 ChatClient 一个引擎 |
| 子 Agent | do-run 用 subagent-config 造全新 ChatClient。父 TCM 无自动渠道流入。用户要共享须在 :subagent-fn 里亲手塞回去——那是踩坑,不是漏洞 |
| 批内调度 | 内联 / executor / 各引擎的可观察语义必须逐字段相同(结果、:serial 退化、:writes 折叠、动态绑定…)。差异只许存在于「怎么跑」 |
| filter | 不在「一致」的禁令里——改变行为是它的本职(§3.2.1)。但它只许改它声明要改的;顺手扰动执行环境(线程模型、binding…)即违反 |
| ambient 状态跨 delegate | 不传导。spawn-worker! 不用 bound-fn* 是故意的,有测试钉住——它看起来像疏漏,别顺手「修好」 |
| 想让某状态穿到子 Agent | 走 subagent-config / prompt 显式传。没有隐式通道 |
| 发现违反 | 改代码立回不变量,不建机器去容忍它(§1.3) |
| 案例 | 判定 | 依据 |
|---|---|---|
*active-pools* 自锁保护 | ❌ 拆除 | 造机器去防「子 agent 共用池死锁」,而子 agent 根本不共享引擎。立不变量即可,机器是多余的(本原则的出处) |
run-on-executor 不传导动态绑定(2026-07-16) | 🐛 必须修 | 违反「边界内一致」:同一工具因 LLM 临场决定发几个 tool-call 而看到不同 binding(1 个走内联 → 可见;≥2 个走 executor → 丢失),换引擎也变。已用 bound-fn* 修复 + 测试钉住两个维度 |
timeout-filter 改虚拟线程时丢传导(2026-07-16) | 🐛 必须修 | 副作用式的环境扰动(§3.2.1):它声明要改的是「超不超时」,却顺手把 binding 弄丢了。已修 |
timeout-filter 挂上才有缺省超时 | ✅ 不是违反 | filter 改变它声明要改的行为,正是本职(§3.2.1)。3.2 初稿的表述会把它误判——已修订 |
spawn-worker! 不传导动态绑定 | ✅ 正确,不改 | 遵守「边界外不流通」:子 Agent 是新 ChatClient + 新 TCM = 新边界,ambient 状态本就不该穿过去 |
deftool :timeout 曾只有挂 filter 才生效(2026-07-16 review#4) | 🐛 已修 | 与 §3 无关,是 §1 的事::serial/:retry/:return-direct 都由 chat-client/react 直接消费,独 :timeout 要用户手动挂 filter——「声明了却静默无效」正是我们要修的那个 bug 换了个位置。现由 chat-client/invoke-tool 强制(单次调用的时间上限是它的职责),filter 退为「只给未声明的工具兜缺省」 |
一次「用错工具」的复盘(2026-07-16,本原则被提级的直接原因): 上一行那个问题,作者最初是拿 §1 四问去答的——结论是「无具体调用方 → 假想 需求 → 待定」。结论碰巧不错,但依据是错的:不传导不是「还没有人要」,而是 原则上就不该。真需求来了,§1 的答案会翻转(有人要就建),§3 的不会(有人要 也不给隐式通道,只给显式的
subagent-config)。为什么会拿错工具:本原则当时只是 §1.3 表格里的一个括号举例——被埋在另一 条原则的插图里,真要用时想不起来。这正是本文开篇那句话的又一个实例:原则散在 个案里的下场,就是下一份文档再把它重新推导一遍(§1 自己就是这么被提出来的)。 提级即是对症。
它们从三个方向管同一件事——框架该长什么、不该长什么:
§2 与 §3 都是边界,分工是边界在哪:§2 划在库与外部世界之间(web 框架不许 进来);§3 划在库内部的执行单元之间(ChatClient/TCM 之间不许隐式流通)。
一个提案可能同时受多条约束:
:subagent-fn 里自己传要几行?」。advisor-alignment-design.md §1.3——§1 的出处(「立项判据」)tool-calling-manager-design.md §0.5 / §5 / §4.3.1——§1 的判例现场streaming-async-design.md §0.5——§2 的出处与完整上下文Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |