Liking cljdoc? Tell your friends :D

clj-agent

Clojure AI Agent Framework - ChatClient 中央编排器

English | 中文

目录


项目概述

clj-agent 是一个 Clojure AI Agent 框架,提供从简单对话到工具调用的完整解决方案:

  • ChatClient + Tool 编排:deftool 宏定义工具,build-chat-client 声明式注册并统一调度
  • 多级 Invoke API:invoke-tool(函数调用)、invoke-chat(纯 LLM);工具调用循环由 SimpleAgent 提供
  • Filter 中间件:洋葱式 around 链(对标 Spring AI 2.0 Advisor),:chat / :tool / :iteration / :turn / :token-xform 五类钩子,可短路/重试/计时/递归重入
  • Spring AI 2.0 Advisor 对齐:ToolSearch 渐进式工具披露(省 34–64% token)、return-direct、可插拔续跑判据、SafeGuard 敏感词、RAG 注入、结构化输出自我修正、RE2 重读——检索/向量库一律经协议注入,框架零新增依赖(见 docs/advisor-alignment-design.md)
  • ChatModel 抽象:LLM 服务通过 {:chat-fn :stream-fn} map 接入,无耦合
  • 多 Provider 支持:三大协议栈 —— OpenAI Chat Completions(common/base)、Anthropic Messages(anthropic)、OpenAI Responses(openai-responses)—— 以及 DeepSeek、Zhipu、Ollama、Gemini、Mistral、MiniMax、DashScope(阿里云)、xAI、Moonshot、OpenRouter、SiliconFlow 及 OpenAI 兼容协议
  • 多模态输入:中立内容部件(图片 / PDF / 音频)一份历史两家 wire 各自成型;不支持的组合当场报错而非静默丢内容
  • 文本向量化:IEmbeddingProvider 独立可选协议 + 内置 8 家端点(OpenAI / 智谱 / SiliconFlow / DashScope 原生 / Ollama 等),自动批次切片
  • SimpleAgent 封装:同步有状态对话,可选 pause/resume 敏感工具审批;LLM/工具异常归一化为 {:status :error :error <规范错误 map>}(可配 :on-error)
  • 统一错误模型:失败统一用规范错误 map {: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 始终不可重试)
  • ChatMemory:按 conversation-id 持久化对话历史(in-memory / windowed / SQLite,SQLite store 实现 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-clientfilter 洋葱链 + 工具装配;两个 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               # 根依赖配置

快速开始

在项目中使用(不发布到 Clojars)

方式 A:本地路径依赖(推荐开发时使用)

在你的项目 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"}}}

方式 B:Git 依赖(推荐团队协作)

如果项目已推送到 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"}}}

方式 C:安装到本地 Maven 仓库

先打包并安装到 ~/.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。

方式一:SimpleAgent(推荐入门)

最简单的使用方式,自动管理对话状态:

(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 + 敏感工具审批

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 API(完全控制)

直接使用 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}) "北京天气怎么样?")

核心概念

deftool 宏

同时定义 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

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」+「查一次表」。

ChatModel 接口

一次 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 回调。

  • 判据:canonical error 的 :retryable?(401 永不重试,429/5xx/连接失败重试)
  • Retry-After:服务端给了就听它的,但受 :max-delay 约束(否则 Retry-After: 3600 会让同步线程睡满一小时)
  • 参数三级取值:单次 opts > provider config > 框架默认;:max-retries 0 关闭
  • 流式不重试:token 已投递给 sink,重跑会让下游看到重复内容;流式要容错 请在 turn 层重跑整轮

请求 / 响应类型(两层)

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)

Filter 中间件(洋葱式 around,对标 Spring AI Advisor)

根抽象是 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。

异步入口(Ring / Luminus 异步 handler)

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。

ToolSearch —— 渐进式工具披露(对标 Spring AI 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。

RAG 注入(对标 Spring AI 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)]})

结构化输出校验(对标 Spring AI 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(请求级共享状态)

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

LLM Provider

支持的 Provider

Provider说明环境变量推荐模型
:openaiOpenAI GPT 系列OPENAI_API_KEYgpt-4o, gpt-4.1, o3
:anthropicAnthropic Claude 系列ANTHROPIC_API_KEYclaude-opus-4, claude-sonnet-4
:zhipu智谱 GLM 系列ZHIPU_API_KEYglm-4.7, glm-4.6
:ollama本地 Ollama 模型-llama3, mistral, qwen
:geminiGoogle Gemini(OpenAI 兼容端点)GOOGLE_API_KEYgemini-2.0-flash, gemini-1.5-pro
:mistralMistralMISTRAL_API_KEYmistral-large-latest
:deepseekDeepSeekDEEPSEEK_API_KEYdeepseek-chat, deepseek-reasoner
:minimaxMiniMax(Anthropic 兼容端点)MINIMAX_API_KEYMiniMax-M2
:dashscope阿里云 DashScope(原生 SSE 流式)DASHSCOPE_API_KEYqwen-plus, qwen-max
:xaixAI Grok 系列XAI_API_KEYgrok-4.5, grok-4.3
:moonshotMoonshot Kimi 系列(默认国内站)MOONSHOT_API_KEYkimi-k2.5, kimi-k3
:openrouterOpenRouter 多厂商聚合网关OPENROUTER_API_KEYopenai/gpt-4o-mini 等带前缀 id
:siliconflowSiliconFlow 硅基流动SILICONFLOW_API_KEYQwen/Qwen3-8B, deepseek-ai/DeepSeek-V3
:openai-compatOpenAI 兼容协议自定义取决于后端

各家进阶能力(结构化输出、并行工具调用、reasoning_effort、Anthropic prompt caching / web_search / citations / skills、DeepSeek 推理与前缀续写等)详见 clj-agent-provider README。

关于流式:真增量 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/。

创建 Provider

(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"}))

直接调用 Provider

(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-* 协议方法读取。

多模态输入(图片 / PDF / 音频)

: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)

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 协作

;; 创建专业化 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>,外加一个 docs job 跑 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 绕过。

Live 验证脚本(真实 provider)

单测(293 tests / 1198 assertions)不叩任何网络。但有些性质单测证明不了—— 「模型看到反馈会不会真的改对」「答案到底来自检索还是先验知识」「省 token 是不是 真省」这类问题,只有真模型能回答。这些脚本就是干这个的,共 89 项断言:

脚本断言单测证明不了、只有它能证的事
examples/toolsearch_live_test.clj1150 工具目录省 78% prompt token 且任务质量不掉;附冷/热缓存成本对照——省 token 未必省钱
examples/rag_live_test.clj18语料全为虚构事实 → 对照组答不出、RAG 组答得出,grounding 真的来自检索;空检索两种语义的实跑差异
examples/structured_output_live_test.clj12把「缺少必填字段 birth_year」丢回真实模型,它真的把字段补上了(自我修正不是纸面主张)
examples/safeguard_live_test.clj18拦下时零 LLM 调用;不落库的代价在真实多轮里可见;边界——工具结果里的敏感词照样通过(入口守卫 ≠ 输出守卫)
examples/return_direct_live_test.clj19对照组:同一句合规话术,return-direct 逐字送达 vs 普通工具被模型改写;补落库修复用真实第二轮验证
examples/minimax_agent_live_test.clj—9 个 callback / 自定义 memory & chat-client / :filters 不暴露
examples/p3_replay_acceptance_live_test.clj1验收:走真实 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.clj10消费方视角: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。

依赖

核心依赖:

  • org.clojure/clojure 1.12.6
  • cheshire/cheshire 5.12.0(provider,JSON)
  • com.taoensso/timbre 6.3.0(client / provider,日志)

core 模块零外部依赖;HTTP 传输在零依赖的 clj-http 模块(JDK 内置 java.net.http),无额外依赖。

持久化 ChatMemory(client 模块,SQLite 后端,按需引入 im.ttalk.agent.memory.sqlite):

  • com.github.seancorfield/next.jdbc 1.3.939
  • org.xerial/sqlite-jdbc 3.45.1.0

测试:

  • lambdaisland/kaocha 1.85.1342

许可证

MIT License

Can you improve this documentation? These fine people already did:
David Gao, Daivd Gao & David.Gao
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