Clojure AI Agent Framework - ChatClient 中央编排器
English | 中文
clj-agent 是一个 Clojure AI Agent 框架,提供从简单对话到工具调用的完整解决方案:
deftool 宏定义工具,build-chat-client 声明式注册并统一调度invoke-tool(函数调用)、invoke-chat(纯 LLM);工具调用循环由 SimpleAgent 提供docs/advisor-alignment-design.md){:chat-fn :stream-fn} map 接入,无耦合common/base)、Anthropic Messages(anthropic)、OpenAI Responses(openai-responses)—— 以及 DeepSeek、Zhipu、Ollama、Gemini、Mistral、MiniMax、DashScope(阿里云)、xAI、Moonshot、OpenRouter、SiliconFlow 及 OpenAI 兼容协议IEmbeddingProvider 独立可选协议 + 内置 8 家端点(OpenAI / 智谱 / SiliconFlow / DashScope 原生 / Ollama 等),自动批次切片{:status :error :error <规范错误 map>}(可配 :on-error){:type :message :retryable? :status :provider}(见 im.ttalk.agent.model.error)。各边界封装一致:provider I/O 失败抛 ex-info(data 即规范错误)、配置/解析返回 [:ok]/[:error]、SimpleAgent 返回 {:status :error}、工具错误渲染成字符串喂回 LLM——彼此可单向转换,:retryable?/:status 全程不丢(如 401 始终不可重试)Closeable)采用依赖倒置(DIP):Core 定义协议(端口)+ chat-client 原语;Client 是 Agent 运行时;Provider 实现协议——Client 与 Provider 各自依赖 Core,互不依赖。任何实现 im.ttalk.agent.model/ILLMProvider 的 jar 都能注入 agent。
四层,每层只做一件事——判据是「换一家 provider 要不要重写」:
| 层 | 职责 | 对应 Spring AI |
|---|---|---|
provider.* | 一次 HTTP 往返,认识厂商 wire 格式 | (各 SDK) |
chat-model | 一次逻辑调用:选项合并 → 重试 → 响应归一化 | ChatModel |
chat-client | filter 洋葱链 + 工具装配;两个 invoke 原语 | ChatClient |
simple-agent | 工具循环 + 记忆 + HITL | (ToolCallingAdvisor + 应用层) |
graph TB
subgraph "clj-agent-core(协议 + 原语)"
PROTO[ILLMProvider 协议<br/>im.ttalk.agent.model<br/>中立消息边界]
CM[ChatModel<br/>选项 + 重试 + 归一化]
RT[retry<br/>判据取自 canonical error]
K[ChatClient<br/>filter 链 + 工具装配]
TR[ToolRegistry<br/>工具声明表]
AD[Filter<br/>中间件洋葱链]
T[deftool]
FA[im.ttalk.agent<br/>Facade 入口]
end
subgraph "clj-agent-client(Agent 运行时,依赖 core)"
SA[simple-agent<br/>同步有状态 + pause/resume]
RE[ReAct<br/>工具调用循环]
ME[ChatMemory]
end
subgraph "clj-agent-provider(适配器,依赖 core)"
AN[Anthropic]
OA[OpenAI]
OR[OpenAI Responses]
DS[DeepSeek]
ZP[Zhipu]
OL[Ollama]
GM[Gemini]
MS[Mistral]
MM[MiniMax]
BL[DashScope]
end
FA -.转发.-> K
SA --> K
SA --> RE
K --> T
K --> AD
K --> TR
K --> CM
CM --> RT
RE --> ME
CM --> PROTO
AN & OA & DS & ZP & OL & GM & MS & MM & BL -. 实现 .-> PROTO
工具循环不在 ChatModel——Spring AI 当前主线也把它从 ChatModel 挪进了
ToolCallingAdvisor(advisor 链里)。我们放在 react.clj + :iteration 链,
位置等价。
graph LR
provider[clj-agent-provider<br/>厂商适配器]
client[clj-agent-client<br/>Agent 运行时]
core[clj-agent-core<br/>协议 + chat-client 原语]
provider --> core
client --> core
clj-agent/
├── modules/
│ ├── clj-agent-core/ # 协议(im.ttalk.agent.model) + chat-client 原语(chat_client/tool/filter);零依赖
│ ├── clj-agent-client/ # Agent 运行时(client/react/memory/subagent),依赖 core
│ ├── clj-agent-provider/ # 厂商适配器(im.ttalk.agent.provider.*),实现协议,依赖 core
│ └── clj-agent-agui/ # (可选)Agent Runtime 后台机制 + AG-UI 协议(im.ttalk.agent.agui.*),依赖 core + client
├── examples/ # 使用示例
├── docs/ # 设计文档(索引见 docs/README.md;design-principles.md = 项目级硬约束,先读它)
├── scripts/check_docs.clj # 文档一致性门禁(JVM Clojure,见下)
├── bb.edn # 开发任务入口(bb tasks 看全部)
├── build.clj # 各模块构建/发布(tools.build)
└── deps.edn # 根依赖配置
在你的项目 deps.edn 中使用 :local/root 直接引用本地路径:
;; deps.edn - 引用整个项目
{:deps {im.ttalk/clj-agent {:local/root "/path/to/clj-agent"}}}
;; 或者只引用特定模块
{:deps {im.ttalk/clj-agent-core {:local/root "/path/to/clj-agent/modules/clj-agent-core"}
im.ttalk/clj-agent-client {:local/root "/path/to/clj-agent/modules/clj-agent-client"}
im.ttalk/clj-agent-provider {:local/root "/path/to/clj-agent/modules/clj-agent-provider"}}}
如果项目已推送到 Git 仓库(GitHub/GitLab 等):
;; deps.edn - 使用 commit SHA
{:deps {im.ttalk/clj-agent {:git/url "https://github.com/your-org/clj-agent"
:git/sha "d523507"}}}
;; 使用 tag
{:deps {im.ttalk/clj-agent {:git/url "https://github.com/your-org/clj-agent"
:git/tag "v0.3.0"
:git/sha "d523507"}}}
先打包并安装到 ~/.m2/repository:
cd /path/to/clj-agent
bb release # 五个模块按 core → client → provider → agui → tools 打包 + 装进 ~/.m2
bb release core # 或只装某一个
然后像普通 Maven 依赖一样引用:
;; deps.edn
{:deps {im.ttalk/clj-agent-client {:mvn/version "0.5.xxx"} ;; agent 运行时(引 core)
im.ttalk/clj-agent-provider {:mvn/version "0.5.xxx"} ;; 厂商适配器
im.ttalk/clj-agent-tools {:mvn/version "0.5.xxx"}}} ;; 编码工具集
建议:本地开发调试用方式 A,团队共享或 CI/CD 用方式 B,需要离线使用或与 Maven 生态集成用方式 C。
最简单的使用方式,自动管理对话状态:
(require '[im.ttalk.agent.simple-agent :as sa])
(require '[im.ttalk.agent.tool :refer [deftool]])
(require '[im.ttalk.agent.provider.factory.builder :as factory])
;; 1. 定义工具
(deftool get-weather
"获取天气信息"
[[city :string "城市名称"]]
(str city ": 晴天 25°C"))
;; 2. 创建工具集(tool var 向量)
(def my-tools [#'get-weather])
;; 3. 创建 Provider
(def provider (factory/create-provider-from-env :openai))
;; 4. 创建 Agent
(def agent (sa/create-agent
{:provider provider
:model "gpt-4"
:system-prompt "你是一个天气助手"
:tools my-tools}))
;; 5. 对话(自动累积上下文)
;; chat 返回 {:status :completed|:paused|:error ...}:
;; :completed -> {:text "..." :tool-calls-made [...]}
;; :paused -> 见方式二(敏感工具审批)
;; :error -> {:error {:type :network-error|:provider-error ... :retryable? bool}}(不抛裸异常)
(println (:text (sa/chat agent "北京天气怎么样?")))
(println (:text (sa/chat agent "上海呢?"))) ;; 自动记住上下文
;; 重置对话
(sa/reset! agent)
SimpleAgent 配置 :on-pause 即启用 pause/resume:遇到标记为 :sensitive 的工具时自动暂停,等待人工审批:
(require '[im.ttalk.agent.simple-agent :as sa])
(deftool delete-file
"删除文件"
[[path :string "文件路径"]]
{:sensitive true} ;; 标记为敏感操作
(str "已删除: " path))
(def file-tools [#'delete-file])
(def agent (sa/create-agent
{:provider provider
:model "gpt-4"
:tools file-tools
:on-pause (fn [{:keys [reason]}]
(println "需要审批:" reason))})) ;; 配置即启用 pause/resume
(let [result (sa/chat agent "删除 /tmp/test.txt")]
(when (= :paused (:status result))
(println "待审批工具:" (get-in result [:pending-tool :name]))
(sa/resume agent "approved") ;; 批准
;; (sa/resume agent "rejected") ;; 拒绝
;; (sa/resume agent "rejected" {:message "先退款再删"}) ;; 拒绝带理由(模型直接拿到)
;; (sa/resume agent "approved" {:args {:path "/tmp/b"}}) ;; 编辑参数后批准
;; (sa/resume agent "reply" {:message "选 B 方案"}) ;; 答复即工具结果(ask-user)
))
ask-user 模式:定义一个 body 永不执行的提问工具,:on-tool-call 拦截暂停,
用户答案经 (sa/resume agent "reply" {:message 答案}) 直接作为工具结果回模型
——模型侧看到的就是一次普通的工具往返。
跨进程重启的 HITL:配置 :pause-store(配合 SQLite ChatMemory),暂停快照自动持久化;
重启后用同一 conversation-id + 同一 store 重建 agent,paused?/resume 透明恢复
(累积的 context 状态槽一并恢复):
(require '[im.ttalk.agent.pause :as pause]
'[im.ttalk.agent.memory.sqlite :as sqlite])
(def agent (sa/create-agent {:provider provider :tools file-tools
:memory (sqlite/sqlite-store "agent.db")
:pause-store (pause/sqlite-pause-store "agent.db")
:conversation-id "order-42"
:callbacks {:on-tool-call ...}}))
;; 暂停 → 进程退出 → 重启后同配置重建 → (sa/resume agent "approved")
环境类工具失败(如凭证失效,{:error-class :environment})的暂停同样支持:
(sa/resume agent "retry") 表示环境已修复、重跑失败工具。
Timeline 与多分支:对话日志即时间线,分支 = 前缀复制到新 conversation-id:
(require '[im.ttalk.agent.timeline :as tl])
(def deps {:memory mem :pause-store ps :branch (tl/in-memory-branch-store)})
(tl/fork! deps "main" {:as "exp"}) ;; 全量分支(源暂停中则连带暂停快照——
;; 两支可各自 resume 不同审批决策做对比)
(tl/fork! deps "main" {:at 4}) ;; 在第 4 条消息处开分支(编辑重试:
;; fork 前缀 + 在分支上重发改写后的消息)
(tl/rollback! deps "main" 4) ;; 破坏性截断("重新生成")
(tl/ancestry deps "exp") ;; 血缘回溯
(tl/prune! deps "exp") ;; 删分支(有子分支拒绝)
合法 fork/rollback 点是 turn 边界或暂停点。工具的 :writes 会作为元数据
随 tool-result 消息进历史(审计 + event-sourcing 伏笔),不会发给 LLM。
直接使用 ChatClient 获取最大灵活性:
(require '[im.ttalk.agent.chat-client :as chat-client])
(require '[im.ttalk.agent.filter :as filters])
(require '[im.ttalk.agent.chat-model :as chat-model])
;; 创建 ChatModel(通用:仅凭协议包装任意 provider)
(def cm (chat-model/create-chat-model
provider
{:model "gpt-4"
:max-tokens 4096}))
;; 构建 ChatClient(声明式;chat-client 只提供原语:invoke-chat / invoke-tool)
(def cc
(chat-client/build-chat-client
{:chat-model cm
:tools my-tools ;; tool var 向量
:filters [filters/logging-filter]}))
;; 纯 LLM 调用(经 :chat filter 链,不触发工具)
(let [{:keys [response]} (chat-client/invoke-chat cc
[{:role "user" :content "你好"}]
{})]
(println (:text response)))
;; 单独调用工具(经 :tool filter 链)
(let [{:keys [value]} (chat-client/invoke-tool cc :get-weather
{:city "北京"} nil)]
(println value))
;; 完整的「工具调用循环」是 SimpleAgent 的职责(见上文方式一),不在 chat-client:
;; (require '[im.ttalk.agent.simple-agent :as agent])
;; (agent/chat (agent/create-agent {:provider provider :tools my-tools}) "北京天气怎么样?")
同时定义 Clojure 函数和生成 LLM tool schema:
(deftool fn-name
"描述(会作为 LLM 的 tool description)"
[[param1 :string "参数描述"]
[param2 :int "可选参数" :default 10]
[param3 :boolean "布尔参数"]]
{:sensitive true ;; 可选:标记为敏感操作(SimpleAgent 配置 :on-pause 时会暂停审批)
:context true ;; 可选:读取 Context(函数签名多一个只读 ctx 参数)
:serial true ;; 可选:副作用工具——同批含 serial 工具时整批退化为按序执行
:retry true ;; 可选:幂等工具 opt-in——:transient 类失败自动指数退避重试
:timeout 5000} ;; 可选:超时毫秒——**开箱即生效**,无需挂任何 filter。
;; 缺省不超时;优先级:本声明 > 引擎 {:timeout ms} > 不超时。
;; 语义是「放弃等待」而非「终止执行」,故声明 :retry 须幂等
;; 注意语义是「放弃等待」而非「终止执行」,超时工具声明 :retry 须幂等
;;(或 {:max-retries 2 :initial-delay-ms 200})
(body ...))
;; 支持的参数类型: :string :int :float :boolean :array :object
;; 写共享状态:任意工具返回 {:result r :writes {k v}} 声明写意图(ctx 本身只读);
;; 同一轮的多个 tool-call 并行执行,屏障处按原始序经 :state-slots 的 reducer 折叠:
(chat-client/build-chat-client {:tools [...] :state-slots {:notes {:init [] :reduce conj}}})
;; 未声明的槽默认 last-writer;失败/超时/被拒的调用 writes 不生效(单工具事务性)
;; 失败分层路由(缺省一切错误序列化为结果交给模型——errors are data):
(throw (ex-info "网络抖动" {:error-class :transient})) ;; 声明 :retry 的工具自动重试
(throw (ex-info "凭证失效" {:error-class :environment})) ;; :on-env-error :pause 时屏障处暂停等人
;; 工具内调 provider 的 canonical error(:retryable?/:auth-error)自动获得正确分类
;; 工具名在一个 chat-client 内必须唯一(var 之间、与内联工具之间都算),重名 build-chat-client
;; 当场抛 {:duplicates [...]}——同名的两份 schema 都会发给 LLM,而 handler 只留得下
;; 一个,「模型看到的」与「实际执行的」就此对不上且无运行期症状。要替换某个工具,
;; 请在传 :tools 之前处理自己的列表。:timeout/:retry 的非法值同样装配期即拒。
ChatClient 提供三类 API:
(require '[im.ttalk.agent.chat-client :as chat-client]
'[im.ttalk.agent.tool-registry :as registry]
'[im.ttalk.agent.filter :as flt])
;; Build API - 声明式构建 ChatClient
(chat-client/build-chat-client
{:chat-model cm ;; ChatModel(IChatModel 实现)
:tools my-tools ;; tool var 向量
:filters [filter-def] ;; Filter 列表
:settings {:max-tool-iterations 10}})
;; Invoke API - 调用(两个原语,均经 filter 洋葱链)
(chat-client/invoke-tool cc :fn-name {:arg "val"} context) ;; 调用函数(:tool 链)
(chat-client/invoke-chat cc messages opts) ;; 纯 LLM(:chat 链,不含工具循环)
;; 工具调用循环不在 chat-client —— 见 im.ttalk.agent.simple-agent(create-agent + chat)
;; 工具声明查询 —— 在 tool-registry(都是 tool-meta 表的薄封装,装配期汇好)
(registry/tool-schemas cc) ;; 所有 tool schema(原 (:tools cc))
(:tool-registry cc) ;; ToolRegistry record 本体
(:chat-model cc) ;; 获取 ChatModel
(registry/find-function cc :name) ;; 查找函数
(registry/list-functions cc) ;; 列出所有函数名
(registry/tool-meta cc :name) ;; ToolMeta record(:func-def/:serial/:retry
;; /:timeout/:return-direct),未注册则 nil
(registry/serial-tool? cc :name) ;; / return-direct-tool? / retry-policy
(registry/effective-tool-timeout cc :name) ;; 实际生效的超时(含引擎缺省)
;; filter 链访问 —— 在 filter ns
(flt/filter-hooks cc) ;; 装配期预编译的四条链
(flt/with-filters cc fs) ;; 换链并重编 hooks —— 改 :filters 走这里
或者走 Facade(im.ttalk.agent,对照 beamai beamai.erl)——常用的那些
一个地方找得到,每个函数都是一行转发:
(require '[im.ttalk.agent :as ai])
(ai/deftool get-weather "查天气" [[city :string "城市"]] (str city ": 晴 25°C"))
(def cc (ai/chat-client {:chat-model (ai/chat-model provider {:model "gpt-4"})
:tools [#'get-weather]}))
(ai/chat cc [{:role :user :content "北京天气?"}])
(ai/invoke-tool cc :get-weather {:city "北京"})
(ai/tools cc)
装配期把能算的都算掉:build-chat-client 预折四条 filter 链(存 hooks)、
预 comp :token-xform、把每个工具的全部声明汇成一张 tool-meta 表。运行期
invoke-tool / invoke-chat 只做「给链塞 terminal」+「查一次表」。
一次 LLM 调用的抽象(对标 Spring AI 的 ChatModel)。分工判据是「换一家
provider 要不要重写」:wire 格式要,退避策略不要——所以 Provider 只管「怎么发
这一个请求」,选项合并 / 重试 / 响应归一化都归 ChatModel。
(require '[im.ttalk.agent.chat-model :as chat-model]
'[im.ttalk.agent.model.request :as req])
(defprotocol IChatModel
(call [this request]) ;; ChatRequest -> ChatResponse,**内建重试**
(stream-call [this request on-token]) ;; -> ChatResponse,**不重试**
(model-options [this])) ;; 该 model 的缺省选项
(def cm (chat-model/create-chat-model provider
{:model "gpt-4" :max-tokens 4096
:retry {:max-retries 3}})) ;; 缺省 2 次;:retry false 关闭
(chat-model/call cm (req/chat-request messages {:tools [...]}))
两个实现:DefaultChatModel(包一个 ILLMProvider)与 FnChatModel(包
{:chat-fn :stream-fn} 两个闭包)。历史的裸 map 写法照旧可用——
build-chat-client 对 :chat-model 调 as-chat-model 归一化,传 record、
传 map、传自定义 IChatModel 实现三者等价。
重试在整个 filter 栈之下(与 beamai beamai_chat_model 同一取舍):
:chat filter(memory / 计时 / 记账)
└ ChatModel.call ──┬ 尝试 1 ✗ → 退避 → 尝试 2 ✓ ← 重试在这里面
└ 返回一个响应
filter 看到的必须是「一次逻辑调用」——重试若在链之上,一次网络抖动就会让
memory-filter 把同一轮 delta 写两遍、计时 filter 记出两条记录,而且没有任何
运行期症状。要观测每次真实尝试用 :on-retry 回调。
:retryable?(401 永不重试,429/5xx/连接失败重试)Retry-After:服务端给了就听它的,但受 :max-delay 约束(否则 Retry-After: 3600
会让同步线程睡满一小时):max-retries 0 关闭ChatClientRequest{:request ChatRequest, :context Context, :on-token f} ;; filter 链看到的
└───────┬────────┘
ChatRequest{:messages [...], :options {...}} ;; ChatModel 看到的
多出来的那一层是 :context(请求级共享状态)与 :on-token(流式 sink)——
两者都不下发给 provider。合成一个类型就等于把「不该发出去的」和「要发出去
的」放进同一个 map,再靠一张白名单去筛;分成两层,筛子就不需要了,provider
私有参数(Anthropic 的 :thinking / :service-tier…)也能原样穿过去。
filter 改写请求走存取器,别 assoc-in:
(flt/req-messages req) (flt/with-messages req msgs) (flt/update-messages req f)
(flt/req-option req :tools) (flt/with-option req :tools ts) (flt/with-options req m)
(flt/req-context req) (flt/req-on-token req)
根抽象是 around(req, chain):chain 是下游,由 filter 决定调不调、调几次、前后干什么
(可短路 / 重试 / 计时)。一个 filter 通过 :chat / :tool / :iteration / :turn
四个键挂到对应链上,可任意并存。执行顺序即 :filters 向量中的注册顺序
(无 :order/:phase);靠前的 filter 在最外层(最先看到 req、最后看到 resp)。
四条链的粒度::chat 包单次 LLM 调用(工具循环内每轮执行,memory 在此);
:tool 包单次工具执行(并行任务内各自生效);:iteration 包单轮迭代
(LLM 调用 + 本轮工具批次——与 :chat 同频,差别是它看得见本轮工具跑出了什么,
故单轮预算/单轮重试/基于工具结果的收尾决策在此);:turn 包整个工具循环
(每 turn 一次——RAG 注入、最终答案校验/guardrail、turn 级预算的正确位置)。
闭包链天然"仅下游",:turn 与 :iteration 都可多次 (chain req) 递归重入
(前者重跑整个循环、后者重跑一轮),但 :paused/:cancelled/:error 结果
必须透传。
;; 自定义 filter —— create-filter 接受 name 后跟钩子关键字参数,产出 Filter record
(def my-filter
(filters/create-filter :my-filter
:tool (fn [req chain] ;; around-tool
(println "工具调用前:" (get-in req [:function :name]))
(chain req)))) ;; 不调 chain 即短路
;; 同一 filter 可同时挂 chat 与 tool
(def both
(filters/create-filter :both
:chat (fn [req chain] (chain (update req :messages conj sys-msg)))
:tool (fn [req chain] (chain req))))
;; :iteration —— 看得见本轮工具跑出了什么(:chat 只能看到 LLM 那一半)
(def round-budget
(filters/create-filter :round-budget
:iteration (fn [req chain] ;; req: {:messages :context :index :remaining}
(let [t0 (System/currentTimeMillis)
r (chain req)] ;; r: {:status :continue :messages :context}
(println "第" (:index req) "轮耗时"
(- (System/currentTimeMillis) t0) "ms")
r)))) ;; :paused/:cancelled 必须原样透传
;; 也可直接写 map:{:name :x :chat (fn [req chain] ...) :tool (fn [req chain] ...)}
;; —— build-chat-client 会经 as-filter 归一化成 record,两种写法等价
;; 内置 filter
filters/logging-filter ;; 调用前后日志(:tool)
(filters/logging-chat-filter) ;; LLM 请求/响应日志(:chat,对标 SimpleLoggerAdvisor)
;; 超时不是 filter:deftool {:timeout ms} 或引擎 (…-tool-calling-manager {:timeout ms})
;; 即生效(工具声明优先;都不给则不超时)——见 client README「工具执行引擎」一节
(filters/approval-filter) ;; 敏感工具审批(拒绝则短路)
(filters/validation-turn-filter validate-fn :max-retries 2)
;; 最终答案校验(:turn):不合格带反馈重入循环重试
(filters/safeguard-turn-filter ["敏感词"])
;; 敏感词命中 → 不进循环直接拒答(:turn,对标 SafeGuardAdvisor)
(filters/re-reading-filter) ;; RE2 重读(:turn,对标 ReReadingAdvisor)
;; 注册:filters 向量顺序即洋葱层序(越靠前越外层)
(chat-client/build-chat-client {:chat-model cm :tools tools :filters [my-filter both]})
;; 链类型::chat(invoke-chat,terminal 调 LLM)| :tool(invoke-tool,terminal 调函数)
;; | :iteration(run-tool-loop 单轮)| :turn(run-tool-loop 整体)
;; tool 链契约:请求 {:function :args :context(只读)},响应 {:result (:writes)}——
;; filter 可改写 :args、短路、around;无需(也不应)回传 :context。
;; 注意:同一轮的多个 tool-call 并行执行,交互式审批请放 agent 的 :tool-gate(批前串行),
;; 勿放 tool filter(会在并行任务中并发弹提示)。
;; 改已建好的 chat-client 的 :filters 走 chat-client/with-filters(链在装配期预编译,
;; 直接 assoc 会让 hooks 与之脱钩——有兜底重编译,但白扔装配期成果)。
四条链的完整契约、硬规则与装配期预编译见 docs/filter-chain-design.md。
agent/chat-async 立刻返回 CompletableFuture,整个 turn 跑在虚拟线程上——
HTTP 工作线程不被一次 LLM 往返占住。配 Ring 3 的三参数异步 handler 正好合缝:
(require '[im.ttalk.agent.async :as async] '[im.ttalk.agent.filter :as flt])
(defn chat-handler [request respond raise] ;; Ring 3 异步 handler
(-> (agent/chat-async (session-agent request) (message-of request))
(flt/fmap ->ring-response) ;; 响应侧组合子:同步链上这行也成立
(async/on-complete respond raise))) ;; respond/raise 就是 Ring 那对回调
;; reitit 按 arity 自动识别异步;服务器需开异步(ring-jetty {:async? true} / immutant / http-kit)
["/api/chat" {:post {:handler chat-handler}}]
配套:react/invoke-async、react/resume-async、agent/resume-async(HITL 第二段
同样不阻塞),async/join 阻塞取值、async/vthread 自建异步终端。
异步与同步是同一份实现(只差一个「跑法」参数),返回值形状逐字相同。
写 filter 要注意:走异步入口时 (chain req) 真的返回 deferred,响应侧必须用
flt/fmap / flt/fbind((let [r (chain req)] …) 不报错,但拿到的是 future——
静默错)。同步链上组合子是恒等展开,零开销,所以一律这么写就对了。
异步到哪一层::turn / :iteration / :chat 三条链全链路 deferred;
:tool 终端仍同步(工具是用户代码,阻塞是常态,批次本就在 executor 上跑)。
provider 侧实现可选协议 IAsyncLLMProvider(Anthropic 系与 OpenAI 兼容系已实现)
就走原生异步 HTTP;没实现的由 chat-model/call-async* 用虚拟线程兜底——
任何 provider 都能异步,实现协议只是省掉那根线程。
⚠️ 流式 sink 的线程不保证:异步入口下 on-token 在工作线程上派发(同步入口
仍是调用线程)。单次流内仍串行,:token-xform 不必加锁;但往 UI /
ThreadLocal 写的 sink 要自己切回去。见
docs/token-stream-filter-design.md §2.1。
为什么不把钩子拆成 before/after 见
docs/filter-chain-design.md §2.6;异步层的设计
(可选协议、retry/run-async、兜底)见
docs/async-chat-model-design.md。
可跑示例(离线、无需 API Key,含 8 会话并发实测 / raise / HITL):
examples/async_luminus_handler_example.clj。
ToolSearchToolCallingAdvisor)工具一多,全量 schema 每轮都进 prompt(Spring 实测 28 个工具 ≈ 5K–17K token,
且模型在 30+ 同名工具间选择准确率下降)。渐进式披露把「一次性全塞」换成
「按需检索」:初始只暴露一个 search_tools,模型检索到的工具下一轮才进
工具列表。
(require '[im.ttalk.agent.filter.tool-search :as ts])
(chat-client/build-chat-client
(ts/with-tool-search ;; 工具 / filter / 状态槽三处一次装好
{:chat-model cm
:tools [#'t1 #'t2 ... #'t80]
:filters [(ma/memory-filter store)]}
{:index (ts/keyword-tool-index) ;; 或 (ts/regex-tool-index)
:limit 5
:always-include #{"handoff"}})) ;; 无需检索即常驻的关键工具
索引零依赖、可插拔:内置 keyword-tool-index(名称/描述分词打分,中文按
二元组切分)与 regex-tool-index(名称模式);自带向量库 (reify ts/IToolIndex ...)
注入即可。发现集合随 tool-context 累积,故暂停/resume/持久化全自动正确。
详见 docs/advisor-alignment-design.md §2。
QuestionAnswerAdvisor)每 turn 检索一次并拼进用户问题。不含向量库——检索经 IRetriever 注入:
(require '[im.ttalk.agent.filter.rag :as rag])
(def retriever
(reify rag/IRetriever
(retrieve [_ query top-k]
(map (fn [hit] {:text (:content hit)}) (my-vector-store/search query top-k)))))
(chat-client/build-chat-client
{:chat-model cm
:filters [(ma/memory-filter store)
(rag/qa-turn-filter retriever :top-k 4)]})
StructuredOutputValidationAdvisor)validation-turn-filter 是机制(不合格 → 反馈重入 → 重试上限),
filter/structured-output 是判据(按 JSON Schema 校验 + 人话报错,
模型据此自我修正而非盲目重试):
(require '[im.ttalk.agent.filter.structured-output :as so]
'[cheshire.core :as json])
(filters/validation-turn-filter
(so/validate-fn {:type "object"
:properties {:actor {:type "string"}
:films {:type "array" :items {:type "string"}}}
:required ["actor" "films"]}
:parse-fn #(json/parse-string % true)) ;; core 零依赖,解析器由你注入
:max-retries 2)
;; 不合格时模型收到的反馈:"缺少必填字段 films" / "字段 films[1] 期望 string,实为 integer"
Context 是扁平 map;对工具与 filter 而言是只读环境(conversation-id、用户信息等),
工具的写意图经返回值 :writes 声明、在每轮工具批次的屏障处统一折叠
(并行安全、按调用序确定,见 docs/agent-loop-concurrency-design.md):
(require '[im.ttalk.agent.context :as ctx])
(def my-ctx (ctx/create {:user-id "u123"})) ;; 创建(可带初始变量)
(ctx/context? my-ctx) ;; 谓词
(ctx/get-var my-ctx :user-id) ;; 获取变量
(ctx/set-var my-ctx :key "value") ;; 设置单个变量(返回新 ctx,调用方侧使用)
(ctx/conversation-id my-ctx) ;; 取会话 id
(ctx/with-conversation-id my-ctx "u1") ;; 设会话 id(返回新 ctx)
(ctx/apply-writes my-ctx [{:k 1}] slots) ;; 批次写折叠(框架在屏障处调用)
对话历史不在 Context 里,而是由 ChatMemory store 按 conversation-id 维护(见
im.ttalk.agent.memory/ Memory Filter)。
| Provider | 说明 | 环境变量 | 推荐模型 |
|---|---|---|---|
:openai | OpenAI GPT 系列 | OPENAI_API_KEY | gpt-4o, gpt-4.1, o3 |
:anthropic | Anthropic Claude 系列 | ANTHROPIC_API_KEY | claude-opus-4, claude-sonnet-4 |
:zhipu | 智谱 GLM 系列 | ZHIPU_API_KEY | glm-4.7, glm-4.6 |
:ollama | 本地 Ollama 模型 | - | llama3, mistral, qwen |
:gemini | Google Gemini(OpenAI 兼容端点) | GOOGLE_API_KEY | gemini-2.0-flash, gemini-1.5-pro |
:mistral | Mistral | MISTRAL_API_KEY | mistral-large-latest |
:deepseek | DeepSeek | DEEPSEEK_API_KEY | deepseek-chat, deepseek-reasoner |
:minimax | MiniMax(Anthropic 兼容端点) | MINIMAX_API_KEY | MiniMax-M2 |
:dashscope | 阿里云 DashScope(原生 SSE 流式) | DASHSCOPE_API_KEY | qwen-plus, qwen-max |
:xai | xAI Grok 系列 | XAI_API_KEY | grok-4.5, grok-4.3 |
:moonshot | Moonshot Kimi 系列(默认国内站) | MOONSHOT_API_KEY | kimi-k2.5, kimi-k3 |
:openrouter | OpenRouter 多厂商聚合网关 | OPENROUTER_API_KEY | openai/gpt-4o-mini 等带前缀 id |
:siliconflow | SiliconFlow 硅基流动 | SILICONFLOW_API_KEY | Qwen/Qwen3-8B, deepseek-ai/DeepSeek-V3 |
:openai-compat | OpenAI 兼容协议 | 自定义 | 取决于后端 |
各家进阶能力(结构化输出、并行工具调用、
reasoning_effort、Anthropic prompt caching / web_search / citations / skills、DeepSeek 推理与前缀续写等)详见clj-agent-providerREADME。关于流式:真增量 SSE 传输基于 JDK
java.net.http(见docs/streaming-async-design.md), 已接入主链路——client/chat-stream在 ReAct 循环里逐 token 流出、on-complete落库, 与同步对话历史不分叉,并支持取消令牌(断连即停)。所有内置 provider 均支持流式 (含 DashScope 原生 SSE:X-DashScope-SSE+incremental_output);个别 provider 若不支持, chat-model 会自动回退同步并把全文作为单个 token emit。 Web 框架(http-kit / Undertow / Jetty / Aleph)的 WebSocket/SSE 集成示例见examples/streaming/。
(require '[im.ttalk.agent.provider.factory.builder :as factory])
;; 方式 1: 从环境变量自动配置(推荐)
(def provider (factory/create-provider-from-env :openai))
;; 方式 2: 手动指定配置
(def provider (factory/create-provider :anthropic
{:api-key "sk-..."
:base-url "https://api.anthropic.com"}))
;; 方式 3: OpenAI 兼容协议(vLLM、LocalAI、LM Studio 等)
(def provider (factory/create-provider :openai-compat
{:api-key "key"
:base-url "http://localhost:8000/v1"}))
;; 方式 4: 智谱 GLM(国产大模型)
(require '[im.ttalk.agent.provider.zhipu :as zhipu])
(def provider (zhipu/create-provider
{:api-key (System/getenv "ZHIPU_API_KEY")
:base-url "https://open.bigmodel.cn/api/paas/v4"}))
;; 方式 5: 本地 Ollama
(require '[im.ttalk.agent.provider.ollama :as ollama])
(def provider (ollama/create-provider
{:base-url "http://localhost:11434"}))
(require '[im.ttalk.agent.model :as proto])
;; 简单对话
(proto/call-simple provider
{:model "gpt-4" :max-tokens 1024}
[{:role "user" :content "你好"}])
;; => "你好!有什么我可以帮助你的吗?"
;; 带工具的调用:走协议方法 call-llm(第 4 个参数即 tools)
(def resp (proto/call-llm provider
{:model "gpt-4" :max-tokens 1024}
[{:role "user" :content "北京天气怎么样?"}]
[{:name "get-weather" :description "获取天气"
:input_schema {:type "object" :properties {}}}]))
;; call-llm 返回的是**厂商原始响应**,用协议方法取内容:
(proto/extract-text provider resp) ;; => "..."
(proto/extract-tool-calls provider resp) ;; => [{:id "..." :name "get-weather" :args {:city "北京"}}]
归一化(统一成
ILLMResponse)发生在chat-model/create-chat-model里;直调 provider 拿到的是原始响应,故需经extract-*协议方法读取。
:content 除字符串外可以是中立内容部件向量,由各 provider 的 wire 转换器翻译
成自家形状——同一份对话历史换 provider 不用改:
(require '[im.ttalk.agent.model.content :as content]
'[im.ttalk.agent.model.message :as msg])
;; SimpleAgent:message 位置直接给部件向量(内部照常包成 user 消息)
(sa/chat agent [(content/text-part "这张图里有几只猫?")
(content/image-part "https://example.com/cats.png")])
;; 本地文件自动 base64 + 猜 media type;内联 base64 须显式给 :media-type
(msg/user [(content/image-part (clojure.java.io/file "cat.png"))])
(msg/user [(content/file-part pdf-b64 {:media-type "application/pdf" :filename "报告.pdf"})])
OpenAI 兼容端点收 image_url / input_audio(wav、mp3)/ file.file_data(PDF);
Anthropic 收 image / document 块,不收音频。不支持的组合抛
:validation-error(不可重试),不静默丢内容。厂商原生块(:type 为字符串)
照旧原样透传,可与中立部件混用。详见
clj-agent-provider README。
embedding 模型与对话模型是两种模型,因此走独立实例 + 独立可选协议
(IEmbeddingProvider,无 Object 兜底 → 能力探测不撒谎):
(require '[im.ttalk.agent.provider.embeddings :as emb])
(def e (emb/create-provider :openai)) ;; 亦支持 :zhipu :siliconflow :dashscope
;; :ollama :gemini :mistral :openai-compat :mock
(emb/embed e ["你好" "世界"])
;; => {:embeddings [[...] [...]] :model "text-embedding-3-small"
;; :usage {:input-tokens 8 :total-tokens 8} :provider :openai}
(emb/embed-one e {:dimensions 256} "只要一条")
超批自动切片、按原序拼回、usage 累加;返回向量与入参同序等长,条数对不上宁可
抛错也不给错位向量。Anthropic 没有 embedding 服务 → 表里没有条目,
(emb/supported-providers) 一看便知。
;; 创建专业化 Agent
(def researcher (sa/create-agent
{:provider provider
:model "gpt-4"
:tools [web-search-plugin]
:system-prompt "你是研究员,负责查找和整理信息。"}))
(def writer (sa/create-agent
{:provider provider
:model "gpt-4"
:tools []
:system-prompt "你是写作专家,负责将信息整理成文章。"}))
;; 协作流程
(defn research-and-write [topic]
(let [;; 研究员收集信息
research-result (sa/chat researcher (str "研究主题: " topic))
facts (:text research-result)
;; 将研究结果传给写作者
article (sa/chat writer (str "基于以下信息写一篇文章:\n" facts))]
(:text article)))
开发任务统一走 babashka(bb tasks 列全部):
bb test # 五个模块全测;bb test core 只测一个
bb check-docs # 文档一致性门禁(幽灵 API / 模块索引缺漏)
bb check # test + check-docs,提交前跑这个
bb jar # 打包五个模块;bb jar core 只打一个
bb install # 装到本地 ~/.m2
bb release # 逐模块 clean → jar → install(发布前完整流程)
bb deploy # 推 Clojars(需 CLOJARS_USERNAME / CLOJARS_PASSWORD)
bb version # 当前版本号(0.4.<git-count-revs>)
bb repl # 带全部模块源码的 REPL
bb repl simpleagent_examples # 先加载某个 example 再进 REPL
构建/发布本体在根 build.clj(tools.build),bb 只是入口——也可以直接
clojure -T:build jar :module core。五个模块共用一份构建代码:同一套 pom-data、
同一个 0.4.<git-count> 版本方案,靠 b/set-project-root! 切模块目录。
为什么 client/provider 打包前要先 install core:它俩的
deps.edn里 core 是{:local/root ".."},而write-pom写不出本地路径的合法 Maven 坐标——不处理的话 生成的 pom 会缺失 core 依赖,消费方解析即断。构建期用 alias 的:override-deps把它换成同版本:mvn/version,代价是这个版本的 core 必须已在本地仓库里。bb release逐模块 clean→jar→install 天然满足;单独bb jar client则会自动补一次 core。
CI:
.github/workflows/test.yml在 push / PR 到main时对五个模块并行跑bb test <module>,外加一个docsjob 跑bb check-docs(与本地同一条命令)。
scripts/check_docs.clj)动机:一次排查在六个 README 里挖出一批幽灵 API——功能早被删/改,源码里
甚至留了「已移除」的注释,文档却没跟。最离谱的是 :build-result-msgs:
chat_model.clj 明写它已移除,四个 README 却还在头部特性 bullet 里教人
用它。人肉复查挡不住这个,所以机器化。四项检查:
| 检查 | 抓的是 |
|---|---|
| ns 存在 | README 点名的 im.ttalk.agent.* 必须真实存在(抓到过从未存在的 model.types) |
| ns 覆盖 | 源码里每个 ns 至少被某个 README 提到(抓到过 pause/timeline/dashscope —— 整块功能在索引里隐身) |
| 符号 resolve | 代码块里的 alias/sym 必须能 resolve(抓到过 proto/call-with-tools —— 协议里根本没这方法) |
| 墓碑 | 已删除的 API 不得复活。map 键/宏选项没法靠 resolve 检查,故显式登记——删 API 时往 tombstones 加一条 |
设计取舍:宁可漏报,不可误报。alias 未由同文件 require 绑定即跳过(Spring
类名、my-vector-store/search 占位符、scripts/check_docs.clj 路径因此天然不参检);
注释与字符串字面量在检查前剥掉(否则注释里的「pause/resume」、字符串里的 URL
/anthropic/v1/messages 都会误报)。一个会误报的门禁很快就会被加 || true 绕过。
单测(293 tests / 1198 assertions)不叩任何网络。但有些性质单测证明不了—— 「模型看到反馈会不会真的改对」「答案到底来自检索还是先验知识」「省 token 是不是 真省」这类问题,只有真模型能回答。这些脚本就是干这个的,共 89 项断言:
| 脚本 | 断言 | 单测证明不了、只有它能证的事 |
|---|---|---|
examples/toolsearch_live_test.clj | 11 | 50 工具目录省 78% prompt token 且任务质量不掉;附冷/热缓存成本对照——省 token 未必省钱 |
examples/rag_live_test.clj | 18 | 语料全为虚构事实 → 对照组答不出、RAG 组答得出,grounding 真的来自检索;空检索两种语义的实跑差异 |
examples/structured_output_live_test.clj | 12 | 把「缺少必填字段 birth_year」丢回真实模型,它真的把字段补上了(自我修正不是纸面主张) |
examples/safeguard_live_test.clj | 18 | 拦下时零 LLM 调用;不落库的代价在真实多轮里可见;边界——工具结果里的敏感词照样通过(入口守卫 ≠ 输出守卫) |
examples/return_direct_live_test.clj | 19 | 对照组:同一句合规话术,return-direct 逐字送达 vs 普通工具被模型改写;补落库修复用真实第二轮验证 |
examples/minimax_agent_live_test.clj | — | 9 个 callback / 自定义 memory & chat-client / :filters 不暴露 |
examples/p3_replay_acceptance_live_test.clj | 1 | 验收:走真实 create-agent 全链,确认 thinking 载荷穿过 chat-model→memory→wire 后正确率回到 100%(20/20,修复前基线 82.5%)。单测能钉链路,但「模型拿到载荷后会不会真的答对」只有真模型能回答 |
examples/minimax_thinking_quality_experiment.clj | — | 实验(非门禁):预注册判据的 A/B 质量对照(20 次/臂,7 步链,答案唯一可自动判定)。剥掉 thinking 回传(=框架当前行为)→ 正确率 100%→82.5%、逐轮全对 100%→47.5%,确证轮 n=40/臂 p=0.0059 → 据此立项(n=20 那轮 p=0.0530 未过线,判为功效不足并按规矩维持不立项)。含地板/天花板作废守卫与单侧 Fisher 检验 |
examples/minimax_thinking_replay_experiment.clj | — | 实验(非门禁):三臂对照量化「剥掉 thinking 块回传」(=框架当前行为)的代价——M2.7 无差别,M3 思考频率降 26% 但任务结果全同。修正了一个基于代码推导的设计推论,见 docs/provider-variant-design.md §7 |
examples/release_consumer_live_test.clj | 10 | 消费方视角:deps 只写 client + provider,core 必须由 pom 传递而来;断言 classpath 上是 jar 不是源码目录,再实叩一轮工具调用。单测跑在源码 classpath 上,对 jar/pom 一无所知——pom 缺 core 依赖只在别人的项目里现形(需先 bb release) |
export MINIMAX_API_KEY=... # 兼容旧变量名 MINIMAX_AUTH_TOKEN
clojure -M -e '(load-file "examples/toolsearch_live_test.clj")'
会真实计费;全部通过 exit 0,有失败 exit 1(可进 CI,但需 key)。
写 live 断言的一条规矩:只钉机制(LLM 调用次数、发给 provider 的消息与 工具集、落库形状),绝不钉模型措辞——后者会波动,拿它当断言等于给 CI 埋雷。 模型的实际回答只打印、不断言。需要「模型首轮是否恰好合规」这类分支时,用条件 断言(先判定首轮结果,再断言对应的不变量),两条路都在测机制且永不 flake。
这几个脚本不是装饰:跑真机推翻过三个基于单测的判断(检索工具描述缺一句话会 静默掉召回、关键词索引缺 IDF、prompt cache 会让 token 对照得出反向结论), 也修出过一个静默失效的老脚本。详见
docs/advisor-alignment-design.md§2.3–2.5。
核心依赖:
core 模块零外部依赖;HTTP 传输在零依赖的
clj-http模块(JDK 内置 java.net.http),无额外依赖。
持久化 ChatMemory(client 模块,SQLite 后端,按需引入 im.ttalk.agent.memory.sqlite):
测试:
MIT License
Can you improve this documentation? These fine people already did:
David Gao, Daivd Gao & David.GaoEdit 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 |