Liking cljdoc? Tell your friends :D

clj-agent 设计原则

本文是项目级设计原则的唯一出处(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 用户拍板提为项目级

1. 无真实需求不建(硬约束,不可违反)

抽象要由真实需求触发,不由对齐别人、对称性、或「以后可能要」触发。 用户在自己代码里几行就能等价做到的事,框架不长字段、不开 seam、不加协议。

出处:本是 advisor-alignment-design.md §1.3 的「立项判据」(「出现真实需求时 再抽」),因在 tool-calling-manager-design.md 里被反复重新发现(毙 deftool :backend、毙 PR2/PR3、拆掉一个防御性保护)而提为硬约束。

1.1 理由

  1. 框架的每个字段都是永久 API 面。deftool 多一个 :backend,就永远多一个 要兼容、要文档、要测试、要在每次重构里绕开的东西;用户函数体里的 5 行 HTTP 包装 删了就没了。成本不对称:建的代价是永久的,不建的代价是几行。
  2. 假想需求会把形状带歪。为想象中的场景设计,得不到真实反馈,只能靠对称性和 对标猜——猜出来的形状在真需求到来时往往不合用(:backend :http 只能表达一种 POST 形状,真实 HTTP 工具要自定义 header / 非 JSON body 时立刻退回手写)。
  3. 宿主语言已经免费给的,不要重新实现一遍。Clojure 里函数是一等值, Spring ToolCallback 用接口承担的 transport 多态,在这里由函数体本身承担。

1.2 四问判据

怎么分辨真实需求与假想需求——每个人都觉得自己的需求是真的,所以要逐条过:

问题真实需求假想需求
现在有人要用吗?有具体场景、具体调用方「以后可能」「为了完整」
不建的话用户怎么办?做不到,或代价高到不可接受自己写几行就等价
换来的是什么?能力(不建就没有的事)只是「更声明式 / 更对称 / 更像 Spring」(例外见 §1.5)
触发条件写得出来吗?写得出具体场景写不出,只能说「万一」

1.3 落地约束

情形约束
提新抽象先回答 §1.2 四问。四问里有一问落在「假想」列,不立项;把理由写进否决记录,供以后重启时对照
已建的抽象,论据倒了一条重新称剩下的论据有多重,不假定它跟着变轻,也不假定它还够重
框架的「防御性机器」同样适用——为防一个没人会踩的坑而加的保护,也是无真实需求的建造
已否决的方案保留否决记录,但重启时须重新设计,不得直接捡回旧方案
能立不变量的,不建机器与其造机器去容忍某个用法被违反,不如把不变量写清楚(该不变量已提为 §3)

1.4 案例法

判例都出自 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.5 修订:命名与分层的对标不受本原则管辖(2026-08-25,用户拍板)

§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 末)。


2. 框架无关(硬约束,不可违反)

agent 框架是一个库,不是 web 应用。core 永远不依赖、不捆绑任何 web 框架 (Luminus / Ring / Undertow / Jetty / Aleph / http-kit-server 一律不行)。

出处:streaming-async-design.md §0.5。

2.1 理由

  1. 依赖方向:web 应用依赖本库,不能反过来。core 一旦依赖某 web 框架, 等于强迫所有使用者都用它——而 agent 的场景远不止 web(CLI、桌面、批处理、 MQ 消费者、非 Luminus 的 web 栈…)。
  2. 回调原语就是解耦边界: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 一无所知。
  3. 越薄的边界越长寿:web 框架会换(Luminus 默认 server 就在 http-kit→Immutant→Undertow 间换过几轮),JDK java.net.http + 一个回调契约不会。 绑死某框架 = 跟着它的生命周期走。

2.2 落地约束

层约束
库本体(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

2.3 现状

已满足:chat-stream 只认回调原语、出站 HTTP 全部经 clj-http(JDK java.net.http)、 deps.edn 无任何 Ring/Luminus/Undertow/web-server 依赖。 本原则是「守住现状」,不是「待改造」。


3. 一个 ChatClient 绑定一个 TCM,不跨边界(硬约束,不可违反)

一个 ChatClient 恰好绑定一个 ToolCallingManager,且这个绑定就是执行边界。 边界内必须一致——工具看到的东西不得因批次大小 / 引擎选型 / filter 挂载而变; 边界外不得流通——父 ChatClient 的引擎与环境状态不自动流入子 Agent。

出处:tool-calling-manager-design.md §4.3.1(拆除 *active-pools* 自锁保护时 立的不变量)。原先只是 §1.3 表格里的一个括号举例,2026-07-16 用户拍板提为 项目级硬约束——理由见 §3.4:它被埋在别的原则里,导致真要用时没人想得起来。

3.1 理由

  1. TCM 是执行引擎(线程模型 + 隔离边界 + 调度策略)。「引擎」和「边界」是同一个 东西的两面:ThreadPoolToolCallingManager 的舱壁之所以是舱壁,正因为没有别人 能挤进这个池。让引擎跨边界流动,等于宣布舱壁不隔离。
  2. 边界内一致,是 TCM 可替换的前提。react.clj 明写「引擎只决定『怎么把这批 跑完』,不决定『跑的是什么』」。若换个引擎、或 LLM 多发一个 tool-call,工具 看到的东西就变了,这句话即是空话——用户无法在不改变语义的前提下选型。
  3. 边界外不流通,是子 Agent 之所以是子 Agent。子 Agent 拿到全新 ChatClient + 全新 TCM,本就是「独立执行单元」的定义。任何隐式穿过去的东西(引擎、动态绑定、 ambient 上下文)都在偷偷把两个执行单元焊在一起——要传就显式经 subagent-config。
  4. 它替掉了一整套机器。§1.3「能立不变量的不建机器」的原始案例:与其造 *active-pools* 去检测「子 agent 共用池会不会死锁」,不如立下不变量说它们本就 不共用。不变量是零成本的,机器是永久成本的。

3.2 两个方向的判据(同一条原则,方向相反)

管的是执行环境(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 边界吗?**没隔着 → 必须一致;隔着 → 必须显式,否则不给。

3.2.1 「一致」管环境,不管行为——filter 改变行为是它的本职

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 才有缺省超时」。原则要能判定违反才算硬约束:会把合法 设计判成违反的表述,援引者只能靠猜哪条算数,等于没有判据。

3.3 落地约束

情形约束
ChatClient ↔ TCM1:1。TCM 是 ChatClient record 的字段,经 build-chat-client :tool-manager 注入;一个 ChatClient 一个引擎
子 Agentdo-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)

3.4 案例法

案例判定依据
*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 自己就是这么被提出来的)。 提级即是对症。


三条原则的关系

它们从三个方向管同一件事——框架该长什么、不该长什么:

  • §1 管纵向:这个抽象该不该存在(需求真不真)。
  • §2 管横向:这个依赖该不该跨进来(外部边界破没破)。
  • §3 管内部的横向:这个状态该不该流到那里(内部边界破没破)。

§2 与 §3 都是边界,分工是边界在哪:§2 划在库与外部世界之间(web 框架不许 进来);§3 划在库内部的执行单元之间(ChatClient/TCM 之间不许隐式流通)。

一个提案可能同时受多条约束:

  • 「给 core 加个 SSE 适配器」——§2 直接否(core 不碰 web 框架),§1 也会问「用户 自己往 sink 写 token 要几行?」。
  • 「让子 Agent 继承父 Agent 的线程池」——§3 直接否(跨边界),§1 还会问「用户在 :subagent-fn 里自己传要几行?」。
  • 「造个机器检测跨边界共享」——§3 说不变量已经立了,§1.3 说能立不变量的不建机器。

相关文档

  • 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

Keyboard shortcuts
Ctrl+kJump to recent docs
←Move to previous article
→Move to next article
Ctrl+/Jump to the search field
× close