package com.accounting.config; import jakarta.annotation.PostConstruct; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import java.net.http.HttpClient; import java.time.Duration; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; /** * AI(LLM)接入配置。 * *
走 OpenAI 兼容的 chat/completions 协议,**不绑定任何供应商** —— * DeepSeek / 通义(compatible-mode)/ 智谱 / OpenAI 都只需要改 base-url 和 model: *
* DeepSeek https://api.deepseek.com/v1 * 通义 https://dashscope.aliyuncs.com/compatible-mode/v1 * 智谱 https://open.bigmodel.cn/api/paas/v4 * OpenAI https://api.openai.com/v1 ** 拼接规则是 {@code base-url + "/chat/completions"}。 * *
api-key 只从环境变量读,application.yml 里不写默认值。key 为空时 * {@link #isReady()} 为 false,AI 接口自动降级为「未配置」提示,而不是 500。
*/ @Slf4j @Configuration public class AiConfig { @Value("${ai.enabled:true}") private boolean enabled; /** 不带 /chat/completions 后缀 */ @Value("${ai.base-url:}") private String baseUrl; @Value("${ai.api-key:}") private String apiKey; @Value("${ai.model:}") private String model; /** 工具调用最多循环几轮:模型连续要数据时防止无限循环烧 token */ @Value("${ai.max-tool-rounds:3}") private int maxToolRounds; /** 单次对话最多带多少条历史消息,太久远的不带 */ @Value("${ai.max-history-messages:20}") private int maxHistoryMessages; public boolean isReady() { return enabled && !baseUrl.isBlank() && !apiKey.isBlank() && !model.isBlank(); } /** * 启动时把配置状态打到日志里 —— 配错了不用猜,看 app.log 就知道。 * 注意**永远不打印 api-key 的内容**,只报长度。 */ @PostConstruct public void logConfigStatus() { if (isReady()) { log.info("AI 已启用:base-url={}, model={}, api-key 长度={}", getBaseUrl(), model, apiKey.length()); } else { log.warn("AI 未配置,助手功能将降级(status 返回 false,对话推 error 事件):" + "enabled={}, base-url={}, model={}, api-key={}", enabled, baseUrl.isBlank() ? "(空)" : baseUrl, model.isBlank() ? "(空)" : model, apiKey.isBlank() ? "(空)" : "已设置"); } } /** * 容忍结尾多余的斜杠:否则会拼出 {@code //chat/completions}, * 网关直接 404,而且报错信息完全看不出是这个原因。 */ public String getBaseUrl() { return baseUrl.replaceAll("/+$", ""); } public String getApiKey() { return apiKey; } public String getModel() { return model; } public int getMaxToolRounds() { return maxToolRounds; } public int getMaxHistoryMessages() { return maxHistoryMessages; } /** * 出站调 LLM 用的 HttpClient。 * *刻意不复用现有的 RestTemplate —— 那个 5 秒读超时撑不住 LLM 的流式生成。 * 用 JDK 自带的 HttpClient 是为了**零新增 Maven 依赖**,流式读响应体靠 * {@code BodyHandlers.ofInputStream()}。
*/ @Bean public HttpClient aiHttpClient() { return HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .build(); } /** * SSE 的工作线程池:controller 立刻返回 SseEmitter, * 上游的流式读取和工具循环都在这个池子里跑,不占用 Tomcat 请求线程。 */ @Bean(destroyMethod = "shutdown") public ExecutorService aiExecutor() { return Executors.newCachedThreadPool(r -> { Thread t = new Thread(r, "ai-chat"); t.setDaemon(true); return t; }); } }