OpenAI 兼容 Provider 基础模块
为所有 OpenAI 兼容的 LLM Provider 提供统一的基础实现。 支持: OpenAI, ZhiPu, Ollama, Gemini, Mistral 等。
主要功能:
使用示例:
(require '[im.ttalk.agent.provider.common.base :as base])
;; 创建配置 (def config (base/make-config :openai "https://api.openai.com/v1" "OPENAI_API_KEY"))
;; 创建 Provider (def provider (base/create-provider config))
OpenAI 兼容 Provider 基础模块
为所有 OpenAI 兼容的 LLM Provider 提供统一的基础实现。
支持: OpenAI, ZhiPu, Ollama, Gemini, Mistral 等。
主要功能:
- 统一的 API 调用包装
- 通用的 Provider Record 实现
- 配置管理
使用示例:
(require '[im.ttalk.agent.provider.common.base :as base])
;; 创建配置
(def config (base/make-config
:openai
"https://api.openai.com/v1"
"OPENAI_API_KEY"))
;; 创建 Provider
(def provider (base/create-provider config))厂商业务码 → canonical 错误类型的码表(designs/provider-reliability.md §1;
照搬 Keelson Providers/ProviderBusinessCodes.cs + ChatProviderException.RefineKind)。
为什么要有这张表:限流和欠费在 HTTP 上常常是同一个 429(OpenAI 兼容的
insufficient_quota 就这么干),光看状态码会把「再等一会就有」当成「再等一个月也没有」,
ChatModel 于是对欠费反复退避重试。反过来,欠费被回成 400 时只看状态码会判成参数错。
状态码只给初判,业务码说了算。
判定次序(refine-error,只会「收紧」):
error.type / error.code / 顶层 code / MiniMax base_resp.status_code)→ 定;
provider 专属行优先于通用行;码表是数据(不是 if 链):补新样本往 rules 加一行即可,判定次序不用动。
厂商业务码 → canonical 错误类型的码表(designs/provider-reliability.md §1; 照搬 Keelson `Providers/ProviderBusinessCodes.cs` + `ChatProviderException.RefineKind`)。 **为什么要有这张表**:限流和欠费在 HTTP 上常常是同一个 429(OpenAI 兼容的 `insufficient_quota` 就这么干),光看状态码会把「再等一会就有」当成「再等一个月也没有」, ChatModel 于是对欠费反复退避重试。反过来,欠费被回成 400 时只看状态码会判成参数错。 **状态码只给初判,业务码说了算。** 判定次序(`refine-error`,只会「收紧」): 1. 码表命中(`error.type` / `error.code` / 顶层 `code` / MiniMax `base_resp.status_code`)→ 定; provider 专属行优先于通用行; 2. 402 → 欠费; 3. 原判是 :validation-error / :provider-error 且文案像「上下文超长」→ :context-overflow-error; 4. 原判是 auth / validation / rate-limit → 维持(**文案绝不凭空造出限流,也不把限流改成欠费**); 5. 其余(5xx / provider)文案像欠费 → :quota-exhausted-error。 码表是数据(不是 if 链):补新样本往 `rules` 加一行即可,判定次序不用动。
Anthropic Prompt Caching 策略层(对标 Spring AI AnthropicCacheStrategy)
Anthropic 通过在内容块上挂 cache_control 来缓存请求前缀,命中后该段
输入 token 仅按约 0.1x 计费。要点(以官方为准):
cache_control 标记的是"断点"
(breakpoint),断点之前的全部内容作为可缓存前缀。:none 不缓存(默认) :system 仅缓存 system :tools 仅缓存工具定义 :system-and-tools 缓存 system + tools(两个 breakpoint) :conversation 缓存到"当前用户问题之前"的对话历史 (标记最后一条消息的最后一个内容块) :tool-results 缓存到最后一个 tool_result(多轮工具循环里跨轮复用工具结果, 标记 messages 中最后一个 type=tool_result 的内容块) :system-and-conversation 同时缓存 system 与对话历史(两个 breakpoint)
ttl(:cache-ttl)两种形状:
服务端规则:长 TTL 的断点必须排在短 TTL 之前(渲染顺序 tools → system → messages)。
apply-anthropic-cache 在注入前校验,违者抛 ex-info(:type :invalid-cache-ttl),
不等服务端 400。任一断点为 "1h" 时 anthropic provider 自动并入 beta
extended-cache-ttl-2025-04-11(兼容端点如 MiniMax /anthropic 少了它会静默退回 5m)。
用法见 apply-anthropic-cache。
Anthropic Prompt Caching 策略层(对标 Spring AI AnthropicCacheStrategy)
========================================
背景
========================================
Anthropic 通过在内容块上挂 `cache_control` 来缓存请求前缀,命中后该段
输入 token 仅按约 0.1x 计费。要点(以官方为准):
- 渲染顺序:tools -> system -> messages。`cache_control` 标记的是"断点"
(breakpoint),断点之前的全部内容作为可缓存前缀。
- 单请求最多 4 个 breakpoint。
- 最小可缓存前缀与模型相关(Opus/Haiku4.5 = 4096 token,Sonnet4.x = 1024/2048);
过短不会缓存(无报错,cache_creation_input_tokens=0)。
- 任意字节变化都会使该位置之后的缓存失效 —— 稳定内容靠前,易变内容靠后。
========================================
策略(cache-strategy)
========================================
:none 不缓存(默认)
:system 仅缓存 system
:tools 仅缓存工具定义
:system-and-tools 缓存 system + tools(两个 breakpoint)
:conversation 缓存到"当前用户问题之前"的对话历史
(标记最后一条消息的最后一个内容块)
:tool-results 缓存到最后一个 tool_result(多轮工具循环里跨轮复用工具结果,
标记 messages 中最后一个 type=tool_result 的内容块)
:system-and-conversation 同时缓存 system 与对话历史(两个 breakpoint)
ttl(:cache-ttl)两种形状:
- 标量:nil -> 默认 5 分钟;"1h" -> 1 小时;本策略的**所有**断点同一档(老用法)。
- 按断点给档的 map:键 :tools / :system / :conversation / :tool-results,
缺键或 nil = 5 分钟(cache_control 不带 ttl 字段)。典型:
{:cache-strategy :system-and-conversation
:cache-ttl {:system "1h" :conversation nil}}
⇒ 前缀(tools+system)1h、对话尾巴 5m——尾巴每轮被取代,付 1h 的 2× 写入费纯亏
(feedbacks/2026-11-10-provider-request-hook-and-two-tier-cache-ttl.md)。
服务端规则:**长 TTL 的断点必须排在短 TTL 之前**(渲染顺序 tools → system → messages)。
apply-anthropic-cache 在注入前校验,违者抛 ex-info(:type :invalid-cache-ttl),
不等服务端 400。任一断点为 "1h" 时 anthropic provider 自动并入 beta
`extended-cache-ttl-2025-04-11`(兼容端点如 MiniMax /anthropic 少了它会静默退回 5m)。
用法见 apply-anthropic-cache。Embedding HTTP 调用与响应归一(两种线上形态)
OpenAI 兼容 POST {base}/embeddings
body {:model .. :input ["a" "b"] :dimensions n? :encoding_format ..}
resp {:data [{:index 0 :embedding [..]} ..] :model .. :usage {:prompt_tokens ..}}
覆盖:OpenAI / 智谱 / SiliconFlow / Ollama / Gemini(兼容端点) / Mistral / xAI …
DashScope 原生 POST .../services/embeddings/text-embedding/text-embedding
body {:model .. :input {:texts [..]} :parameters {:dimension n :text_type ..}}
resp {:output {:embeddings [{:text_index 0 :embedding [..]}]} :usage {:total_tokens n}}
两条路的公共约定:
:batch-size),usage 逐片累加Embedding HTTP 调用与响应归一(两种线上形态)
1. **OpenAI 兼容** `POST {base}/embeddings`
body {:model .. :input ["a" "b"] :dimensions n? :encoding_format ..}
resp {:data [{:index 0 :embedding [..]} ..] :model .. :usage {:prompt_tokens ..}}
覆盖:OpenAI / 智谱 / SiliconFlow / Ollama / Gemini(兼容端点) / Mistral / xAI …
2. **DashScope 原生** `POST .../services/embeddings/text-embedding/text-embedding`
body {:model .. :input {:texts [..]} :parameters {:dimension n :text_type ..}}
resp {:output {:embeddings [{:text_index 0 :embedding [..]}]} :usage {:total_tokens n}}
两条路的公共约定:
- 返回向量与入参 texts **同序等长**(按服务端返回的 index 重排,不假定顺序)
- 超批自动切片(`:batch-size`),usage 逐片累加
- 失败一律抛规范错误(D5),与 chat 路径同一套词汇轻量有界缓存工具。
与 clojure.core/memoize 的区别:容量有界(超限整体清空重建), 不会随参数多样性无限增长——适合做库代码里的转换缓存。
轻量有界缓存工具。 与 clojure.core/memoize 的区别:容量有界(超限整体清空重建), 不会随参数多样性无限增长——适合做库代码里的转换缓存。
OpenAI 兼容 API 调用
提供 OpenAI 风格 API 的通用调用代码,供多个 Provider 复用:
使用示例:
(require '[im.ttalk.agent.provider.common.openai-compat :as compat])
;; 同步调用 (compat/call-api api-url api-key config messages tools)
;; 流式调用 (compat/call-api-stream api-url api-key config messages tools on-token)
OpenAI 兼容 API 调用 提供 OpenAI 风格 API 的通用调用代码,供多个 Provider 复用: - OpenAI - 智谱 AI (ZhiPu) - Ollama - 其他 OpenAI 兼容 API 使用示例: (require '[im.ttalk.agent.provider.common.openai-compat :as compat]) ;; 同步调用 (compat/call-api api-url api-key config messages tools) ;; 流式调用 (compat/call-api-stream api-url api-key config messages tools on-token)
统一限流快照(designs/provider-reliability.md §6;Keelson Providers/RateLimitInfo.cs)。
OpenAI 的 x-ratelimit-{limit,remaining,reset}-{requests,tokens} 与 Anthropic 的
anthropic-ratelimit-{requests,tokens}-{limit,remaining,reset} 折成同一形状:
{:requests-limit :requests-remaining :requests-reset :tokens-limit :tokens-remaining :tokens-reset}
头缺哪格就没有哪格;全空 → nil(不挂)。有 retry-after 头时另带 :retry-after
(Anthropic 旧 parse-rate-limit 形状的超集,向后兼容)。
头值可以是字符串,也可以是 java.net.http 给的 List<String>(http.client 的
http-headers->map 曾经这么存)——取第一个值(im.ttalk.http/header)。
统一限流快照(designs/provider-reliability.md §6;Keelson `Providers/RateLimitInfo.cs`)。
OpenAI 的 `x-ratelimit-{limit,remaining,reset}-{requests,tokens}` 与 Anthropic 的
`anthropic-ratelimit-{requests,tokens}-{limit,remaining,reset}` 折成同一形状:
{:requests-limit :requests-remaining :requests-reset
:tokens-limit :tokens-remaining :tokens-reset}
头缺哪格就没有哪格;全空 → nil(不挂)。有 `retry-after` 头时另带 `:retry-after`
(Anthropic 旧 `parse-rate-limit` 形状的超集,向后兼容)。
头值可以是字符串,也可以是 `java.net.http` 给的 `List<String>`(`http.client` 的
`http-headers->map` 曾经这么存)——取第一个值(`im.ttalk.http/header`)。宿主对最终请求体的最后一改:config :configure-request。
形状:(fn [payload] payload),payload 是本库组好的完整请求体(wire 形态的
map,键为 snake_case 关键字)——思考字段(common.thinking 生成的)、:extra-body、
缓存断点(Anthropic)、流式的 :stream true 都已在场,钩子看得见、删得掉、
改写得了;返回值原样作为请求体发出。
为什么要有(feedbacks/2026-11-10-provider-request-hook-and-two-tier-cache-ttl.md):
:extra-body 只能 merge,删不掉库生成的键。qwen 收到 thinking /
reasoning_effort 回 400(它要 enable_thinking)、kimi-k2 收到 temperature
回 400——宿主手里只有 config,算不出最终请求体,于是绕不过去。
对标 Keelson.Agent 的 ConfigureRequest(Action<JsonObject>)。
约定:
ex-info(:type :invalid-request-hook),
不静默发出半截请求体。宿主对**最终请求体**的最后一改:config `:configure-request`。 形状:`(fn [payload] payload)`,payload 是本库组好的完整请求体(wire 形态的 map,键为 snake_case 关键字)——思考字段(common.thinking 生成的)、`:extra-body`、 缓存断点(Anthropic)、流式的 `:stream true` **都已在场**,钩子看得见、删得掉、 改写得了;返回值原样作为请求体发出。 为什么要有(feedbacks/2026-11-10-provider-request-hook-and-two-tier-cache-ttl.md): `:extra-body` 只能 merge,删不掉库生成的键。qwen 收到 `thinking` / `reasoning_effort` 回 400(它要 `enable_thinking`)、kimi-k2 收到 `temperature` 回 400——宿主手里只有 config,算不出最终请求体,于是绕不过去。 对标 Keelson.Agent 的 `ConfigureRequest`(`Action<JsonObject>`)。 约定: - 同步 / 流式 / deferred 各条路径**同一处**调用,发出之前最后一步。 - 返回非 map 视为宿主 bug,当场抛 `ex-info`(`:type :invalid-request-hook`), 不静默发出半截请求体。 - 钩子抛出的异常原样上抛(不包装、不吞)。
响应解析模块
提供统一的 LLM API 响应解析功能,支持 OpenAI 格式的响应。 将 OpenAI 原始响应转换为统一的内部格式。
使用示例:
(require '[im.ttalk.agent.provider.common.response-parser :as parser])
;; 提取工具调用 (parser/extract-tool-calls response)
;; 提取文本 (parser/extract-text response)
;; 标准化响应为统一格式 (parser/normalize-response response)
响应解析模块 提供统一的 LLM API 响应解析功能,支持 OpenAI 格式的响应。 将 OpenAI 原始响应转换为统一的内部格式。 使用示例: (require '[im.ttalk.agent.provider.common.response-parser :as parser]) ;; 提取工具调用 (parser/extract-tool-calls response) ;; 提取文本 (parser/extract-text response) ;; 标准化响应为统一格式 (parser/normalize-response response)
thinking 档位统一(designs/provider-reliability.md §7;Keelson Providers/ThinkingKeys.cs、
OpenAICompatibleChatClient.ApplyThinkingCall、AnthropicChatClient.ApplyThinkingCall)。
上层只说一句「档位 / 开关」,这里按各家风格生成 wire:
| config 键 | 含义 |
|---|---|
:thinking-effort | 档位名(字符串 / 关键字),原样上 wire(DeepSeek 的 "max" 也行);有档位即开 |
:thinking-enabled | 只开 / 关,不带档位(effort 在场时被忽略) |
:thinking-style | OpenAI 兼容线的写法::plain / :nested / :top-level / :qwen / :none;缺省按 provider 预设 |
:thinking-budget-tokens | Anthropic 旧式预算(压过档位与「只开」,只有「关」压得过它) |
:thinking-always-on? | Anthropic:关不掉思考的模型,「关」时省略字段 |
不破坏现有透传:config 里显式给了 :thinking(或 OpenAI 的 :reasoning-effort、Responses 的
:reasoning)→ 对应字段不由本 ns 生成,显式值原样发。两个新键都没给 → 什么都不生成。
thinking 档位统一(designs/provider-reliability.md §7;Keelson `Providers/ThinkingKeys.cs`、 `OpenAICompatibleChatClient.ApplyThinkingCall`、`AnthropicChatClient.ApplyThinkingCall`)。 上层只说一句「档位 / 开关」,这里按各家风格生成 wire: | config 键 | 含义 | |---|---| | `:thinking-effort` | 档位名(字符串 / 关键字),**原样上 wire**(DeepSeek 的 "max" 也行);有档位即开 | | `:thinking-enabled` | 只开 / 关,不带档位(effort 在场时被忽略) | | `:thinking-style` | OpenAI 兼容线的写法:`:plain` / `:nested` / `:top-level` / `:qwen` / `:none`;缺省按 provider 预设 | | `:thinking-budget-tokens` | Anthropic 旧式预算(压过档位与「只开」,只有「关」压得过它) | | `:thinking-always-on?` | Anthropic:关不掉思考的模型,「关」时省略字段 | **不破坏现有透传**:config 里显式给了 `:thinking`(或 OpenAI 的 `:reasoning-effort`、Responses 的 `:reasoning`)→ 对应字段不由本 ns 生成,显式值原样发。两个新键都没给 → 什么都不生成。
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 |