Commit 7b4fbe386a2d48fa9a9c1e6d9079124219068fe0

Authored by zichun
1 parent 4c9d29c3

refactor(llm): switch to OpenAI-compatible API; unify model to qwen3.6-27b-iq3

- Replace langchain4j-ollama with langchain4j-open-ai; AgentFactory/
  QueryTool depend on ChatModel/StreamingChatModel interfaces
- OllamaJsonClient -> LlmJsonClient: raw /v1/chat/completions with
  response_format json_schema; single OkHttpClient instance
- Thinking off at all 3 call sites via reasoning_effort=none (the only
  switch that works on /v1; think:false and /no_think are ignored)
- Drop client-side length params (num_ctx/num_predict/max_tokens) —
  server-side OLLAMA_CONTEXT_LENGTH=16384 on xlyllm covers them
- Unified tracing: TracingChatModelListener.record() shared by listener
  callbacks and LlmJsonClient; per-request model name from ctx; sql
  model now has the listener too (was untraced and mislabeled)
- Config keys langchain4j.ollama.* -> llm.{base-url,api-key,chat-model,
  sql-model}; both models = qwen3.6-27b-iq3:latest (tools+thinking+
  vision, verified: streaming tool-calls / json_schema / reasoning off)

Verified end-to-end on :8199: intent gate traced (340/28 tokens), agent
tool loop, correct answers; warm 1-4s, cold load ~96s (KEEP_ALIVE=30s).
@@ -90,7 +90,7 @@ @@ -90,7 +90,7 @@
90 <version>${okhttp.version}</version> 90 <version>${okhttp.version}</version>
91 </dependency> 91 </dependency>
92 92
93 - <!-- LangChain4j 核心 + Ollama --> 93 + <!-- LangChain4j 核心 + OpenAI 兼容协议(Ollama /v1、云端 API 皆可,走配置) -->
94 <dependency> 94 <dependency>
95 <groupId>dev.langchain4j</groupId> 95 <groupId>dev.langchain4j</groupId>
96 <artifactId>langchain4j</artifactId> 96 <artifactId>langchain4j</artifactId>
@@ -98,7 +98,7 @@ @@ -98,7 +98,7 @@
98 </dependency> 98 </dependency>
99 <dependency> 99 <dependency>
100 <groupId>dev.langchain4j</groupId> 100 <groupId>dev.langchain4j</groupId>
101 - <artifactId>langchain4j-ollama</artifactId> 101 + <artifactId>langchain4j-open-ai</artifactId>
102 <version>${langchain4j.version}</version> 102 <version>${langchain4j.version}</version>
103 </dependency> 103 </dependency>
104 104
src/main/java/com/xly/config/AgentConfig.java
1 package com.xly.config; 1 package com.xly.config;
2 2
3 -import dev.langchain4j.model.ollama.OllamaStreamingChatModel; 3 +import dev.langchain4j.model.chat.StreamingChatModel;
  4 +import dev.langchain4j.model.openai.OpenAiStreamingChatModel;
4 import org.springframework.beans.factory.annotation.Autowired; 5 import org.springframework.beans.factory.annotation.Autowired;
5 import org.springframework.beans.factory.annotation.Value; 6 import org.springframework.beans.factory.annotation.Value;
6 import org.springframework.context.annotation.Bean; 7 import org.springframework.context.annotation.Bean;
@@ -10,7 +11,7 @@ import java.time.Duration; @@ -10,7 +11,7 @@ import java.time.Duration;
10 import java.util.List; 11 import java.util.List;
11 12
12 /** 13 /**
13 - * agent 用的流式模型 Bean 14 + * agent 用的流式模型 Bean(OpenAI 兼容协议:Ollama /v1、vLLM、云端网关皆可,换供应商只改配置)
14 * 15 *
15 * <p>单一 ReAct agent 的**组装**已移到 {@link AgentFactory}(按每次请求的身份新建携带 token/权限的工具实例, 16 * <p>单一 ReAct agent 的**组装**已移到 {@link AgentFactory}(按每次请求的身份新建携带 token/权限的工具实例,
16 * 见 §5/§7 per-call context)。本类只保留全局复用的流式模型。 17 * 见 §5/§7 per-call context)。本类只保留全局复用的流式模型。
@@ -18,27 +19,28 @@ import java.util.List; @@ -18,27 +19,28 @@ import java.util.List;
18 @Configuration 19 @Configuration
19 public class AgentConfig { 20 public class AgentConfig {
20 21
21 - @Value("${langchain4j.ollama.base-url}")  
22 - private String ollamaUrl; 22 + @Value("${llm.base-url}")
  23 + private String baseUrl;
23 24
24 - @Value("${langchain4j.ollama.chat-model-name}") 25 + @Value("${llm.api-key:ollama}")
  26 + private String apiKey;
  27 +
  28 + @Value("${llm.chat-model}")
25 private String chatModelName; 29 private String chatModelName;
26 30
27 @Autowired 31 @Autowired
28 private TracingChatModelListener tracingListener; 32 private TracingChatModelListener tracingListener;
29 33
30 - /** 专供 agent 的流式模型:低温度利于稳定的工具调用,较大 numPredict 避免答复被截断。 */ 34 + /** 专供 agent 的流式模型:低温度利于稳定的工具调用。 */
31 @Bean("agentStreamingModel") 35 @Bean("agentStreamingModel")
32 - public OllamaStreamingChatModel agentStreamingModel() {  
33 - return OllamaStreamingChatModel.builder()  
34 - .baseUrl(ollamaUrl) 36 + public StreamingChatModel agentStreamingModel() {
  37 + return OpenAiStreamingChatModel.builder()
  38 + .baseUrl(baseUrl)
  39 + .apiKey(apiKey)
35 .modelName(chatModelName) 40 .modelName(chatModelName)
36 .temperature(0.1) 41 .temperature(0.1)
37 .topP(0.9) 42 .topP(0.9)
38 - .numPredict(2048)  
39 - // qwen3 支持「思考」模式,但会显著拖慢交互;关闭它 -> 快,且思考不会混进回答  
40 - .think(false)  
41 - .returnThinking(false) 43 + .reasoningEffort("none")
42 .listeners(List.of(tracingListener)) 44 .listeners(List.of(tracingListener))
43 .timeout(Duration.ofSeconds(180)) 45 .timeout(Duration.ofSeconds(180))
44 .build(); 46 .build();
src/main/java/com/xly/config/AgentFactory.java
@@ -18,8 +18,8 @@ import com.xly.tool.ProposeWriteTool; @@ -18,8 +18,8 @@ import com.xly.tool.ProposeWriteTool;
18 import com.xly.tool.QueryTool; 18 import com.xly.tool.QueryTool;
19 import com.xly.tool.SkillTool; 19 import com.xly.tool.SkillTool;
20 import dev.langchain4j.memory.chat.MessageWindowChatMemory; 20 import dev.langchain4j.memory.chat.MessageWindowChatMemory;
21 -import dev.langchain4j.model.ollama.OllamaChatModel;  
22 -import dev.langchain4j.model.ollama.OllamaStreamingChatModel; 21 +import dev.langchain4j.model.chat.ChatModel;
  22 +import dev.langchain4j.model.chat.StreamingChatModel;
23 import dev.langchain4j.service.AiServices; 23 import dev.langchain4j.service.AiServices;
24 import org.springframework.beans.factory.annotation.Qualifier; 24 import org.springframework.beans.factory.annotation.Qualifier;
25 import org.springframework.jdbc.core.JdbcTemplate; 25 import org.springframework.jdbc.core.JdbcTemplate;
@@ -38,8 +38,8 @@ import org.springframework.stereotype.Component; @@ -38,8 +38,8 @@ import org.springframework.stereotype.Component;
38 @Component 38 @Component
39 public class AgentFactory { 39 public class AgentFactory {
40 40
41 - private final OllamaStreamingChatModel streamingModel;  
42 - private final OllamaChatModel sqlModel; 41 + private final StreamingChatModel streamingModel;
  42 + private final ChatModel sqlModel;
43 private final RedisChatMemoryStore memoryStore; 43 private final RedisChatMemoryStore memoryStore;
44 private final SystemPromptService systemPromptService; 44 private final SystemPromptService systemPromptService;
45 45
@@ -55,8 +55,8 @@ public class AgentFactory { @@ -55,8 +55,8 @@ public class AgentFactory {
55 private final SkillTool skillTool; 55 private final SkillTool skillTool;
56 private final InteractionTool interactionTool; 56 private final InteractionTool interactionTool;
57 57
58 - public AgentFactory(@Qualifier("agentStreamingModel") OllamaStreamingChatModel streamingModel,  
59 - @Qualifier("sqlChatModel") OllamaChatModel sqlModel, 58 + public AgentFactory(@Qualifier("agentStreamingModel") StreamingChatModel streamingModel,
  59 + @Qualifier("sqlChatModel") ChatModel sqlModel,
60 RedisChatMemoryStore memoryStore, 60 RedisChatMemoryStore memoryStore,
61 SystemPromptService systemPromptService, 61 SystemPromptService systemPromptService,
62 ErpClient erp, JdbcTemplate jdbc, FormResolverService resolver, OpService ops, 62 ErpClient erp, JdbcTemplate jdbc, FormResolverService resolver, OpService ops,
src/main/java/com/xly/config/ModelConfig.java
@@ -3,32 +3,39 @@ package com.xly.config; @@ -3,32 +3,39 @@ package com.xly.config;
3 import com.fasterxml.jackson.databind.ObjectMapper; 3 import com.fasterxml.jackson.databind.ObjectMapper;
4 import com.fasterxml.jackson.databind.SerializationFeature; 4 import com.fasterxml.jackson.databind.SerializationFeature;
5 import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; 5 import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
6 -import dev.langchain4j.model.ollama.OllamaChatModel; 6 +import dev.langchain4j.model.chat.ChatModel;
  7 +import dev.langchain4j.model.openai.OpenAiChatModel;
7 import org.springframework.beans.factory.annotation.Value; 8 import org.springframework.beans.factory.annotation.Value;
8 import org.springframework.context.annotation.Bean; 9 import org.springframework.context.annotation.Bean;
9 import org.springframework.context.annotation.Configuration; 10 import org.springframework.context.annotation.Configuration;
10 import org.springframework.context.annotation.Primary; 11 import org.springframework.context.annotation.Primary;
11 12
12 import java.time.Duration; 13 import java.time.Duration;
  14 +import java.util.List;
13 15
14 @Configuration 16 @Configuration
15 public class ModelConfig { 17 public class ModelConfig {
16 18
17 - @Value("${langchain4j.ollama.base-url}")  
18 - private String chatModelUrl; 19 + @Value("${llm.base-url}")
  20 + private String baseUrl;
19 21
20 - @Value("${langchain4j.ollama.sql-model-name}") 22 + @Value("${llm.api-key:ollama}")
  23 + private String apiKey;
  24 +
  25 + @Value("${llm.sql-model}")
21 private String sqlModelName; 26 private String sqlModelName;
22 27
23 - /** NL2SQL 专用模型(QueryTool 经 AgentFactory 注入)。 */ 28 + /** NL2SQL 专用模型(QueryTool 经 AgentFactory 注入),OpenAI 兼容协议。 */
24 @Bean("sqlChatModel") 29 @Bean("sqlChatModel")
25 - public OllamaChatModel sqlChatModel() {  
26 - return OllamaChatModel.builder()  
27 - .baseUrl(chatModelUrl) 30 + public ChatModel sqlChatModel(TracingChatModelListener tracingListener) {
  31 + return OpenAiChatModel.builder()
  32 + .baseUrl(baseUrl)
  33 + .apiKey(apiKey)
28 .modelName(sqlModelName) 34 .modelName(sqlModelName)
29 .temperature(0.0) 35 .temperature(0.0)
30 .topP(0.95) 36 .topP(0.95)
31 - .numPredict(4096) 37 + .reasoningEffort("none")
  38 + .listeners(List.of(tracingListener))
32 .timeout(Duration.ofSeconds(120)) 39 .timeout(Duration.ofSeconds(120))
33 .maxRetries(3) 40 .maxRetries(3)
34 .build(); 41 .build();
src/main/java/com/xly/config/TracingChatModelListener.java
@@ -48,8 +48,6 @@ public class TracingChatModelListener implements ChatModelListener { @@ -48,8 +48,6 @@ public class TracingChatModelListener implements ChatModelListener {
48 private String publicKey; 48 private String publicKey;
49 @Value("${langfuse.secret-key:}") 49 @Value("${langfuse.secret-key:}")
50 private String secretKey; 50 private String secretKey;
51 - @Value("${langchain4j.ollama.chat-model-name:unknown}")  
52 - private String modelName;  
53 51
54 public TracingChatModelListener(ObjectMapper mapper) { 52 public TracingChatModelListener(ObjectMapper mapper) {
55 this.mapper = mapper; 53 this.mapper = mapper;
@@ -59,11 +57,12 @@ public class TracingChatModelListener implements ChatModelListener { @@ -59,11 +57,12 @@ public class TracingChatModelListener implements ChatModelListener {
59 public void onRequest(ChatModelRequestContext ctx) { 57 public void onRequest(ChatModelRequestContext ctx) {
60 ctx.attributes().put("t0", System.nanoTime()); 58 ctx.attributes().put("t0", System.nanoTime());
61 ctx.attributes().put("startTs", Instant.now().toString()); 59 ctx.attributes().put("startTs", Instant.now().toString());
  60 + String model = ctx.chatRequest() == null ? null : ctx.chatRequest().modelName();
  61 + ctx.attributes().put("model", model == null ? "unknown" : model);
62 } 62 }
63 63
64 @Override 64 @Override
65 public void onResponse(ChatModelResponseContext ctx) { 65 public void onResponse(ChatModelResponseContext ctx) {
66 - long ms = elapsedMs(ctx.attributes().get("t0"));  
67 Integer in = null; 66 Integer in = null;
68 Integer out = null; 67 Integer out = null;
69 try { 68 try {
@@ -74,20 +73,31 @@ public class TracingChatModelListener implements ChatModelListener { @@ -74,20 +73,31 @@ public class TracingChatModelListener implements ChatModelListener {
74 } 73 }
75 } catch (Exception ignore) { 74 } catch (Exception ignore) {
76 } 75 }
77 - log.info("LLM ok {}ms tokens(in/out)={}/{}", ms, in, out);  
78 - exportToLangfuse(String.valueOf(ctx.attributes().get("startTs")), in, out, null); 76 + record(String.valueOf(ctx.attributes().get("model")), ctx.attributes().get("t0"),
  77 + String.valueOf(ctx.attributes().get("startTs")), in, out, null);
79 } 78 }
80 79
81 @Override 80 @Override
82 public void onError(ChatModelErrorContext ctx) { 81 public void onError(ChatModelErrorContext ctx) {
83 Throwable e = ctx.error(); 82 Throwable e = ctx.error();
84 String msg = e == null ? "?" : e.getMessage(); 83 String msg = e == null ? "?" : e.getMessage();
85 - log.warn("LLM error: {}", msg);  
86 - exportToLangfuse(String.valueOf(ctx.attributes().get("startTs")), null, null, msg); 84 + record(String.valueOf(ctx.attributes().get("model")), ctx.attributes().get("t0"),
  85 + String.valueOf(ctx.attributes().get("startTs")), null, null, msg);
  86 + }
  87 +
  88 + /** 统一记录一次 LLM 调用(listener 回调与 {@code LlmJsonClient} 直连路径共用)。 */
  89 + public void record(String model, Object startNanos, String startTs, Integer in, Integer out, String error) {
  90 + long ms = elapsedMs(startNanos);
  91 + if (error == null) {
  92 + log.info("LLM ok {} {}ms tokens(in/out)={}/{}", model, ms, in, out);
  93 + } else {
  94 + log.warn("LLM error {} {}ms: {}", model, ms, error);
  95 + }
  96 + exportToLangfuse(model, startTs, in, out, error);
87 } 97 }
88 98
89 /** 把一条 generation span 转发到 Langfuse(best-effort,异步,失败仅告警)。未启用则直接返回。 */ 99 /** 把一条 generation span 转发到 Langfuse(best-effort,异步,失败仅告警)。未启用则直接返回。 */
90 - private void exportToLangfuse(String startTs, Integer in, Integer out, String error) { 100 + private void exportToLangfuse(String modelName, String startTs, Integer in, Integer out, String error) {
91 if (!langfuseEnabled || publicKey == null || publicKey.isBlank() || secretKey == null || secretKey.isBlank()) { 101 if (!langfuseEnabled || publicKey == null || publicKey.isBlank() || secretKey == null || secretKey.isBlank()) {
92 return; 102 return;
93 } 103 }
src/main/java/com/xly/service/IntentService.java
@@ -13,7 +13,7 @@ import java.util.Map; @@ -13,7 +13,7 @@ import java.util.Map;
13 /** 13 /**
14 * 第 0 阶段意图门(intent gate):把一句用户话分类为 {意图, 单据类型, 带角色的实体, 缺失信息}。 14 * 第 0 阶段意图门(intent gate):把一句用户话分类为 {意图, 单据类型, 带角色的实体, 缺失信息}。
15 * 15 *
16 - * <p>只做**一件窄任务**,用受约束 JSON 解码({@link OllamaJsonClient})。实测在此形态下 16 + * <p>只做**一件窄任务**,用受约束 JSON 解码({@link LlmJsonClient})。实测在此形态下
17 * qwen3:14b 对包括「报价纸盒→纸盒是产品而非客户」「给苏州华为报价彩盒→客户+产品分离」 17 * qwen3:14b 对包括「报价纸盒→纸盒是产品而非客户」「给苏州华为报价彩盒→客户+产品分离」
18 * 「有多少个客户→查询」在内的样本 8/8 正确、~2-3s/次。相较之下,让同一个模型在全部 9 个工具的 18 * 「有多少个客户→查询」在内的样本 8/8 正确、~2-3s/次。相较之下,让同一个模型在全部 9 个工具的
19 * 单次 ReAct 里同时判意图/选工具/编参数则频繁出错(把查询错当新增、更新流程死循环等)。 19 * 单次 ReAct 里同时判意图/选工具/编参数则频繁出错(把查询错当新增、更新流程死循环等)。
@@ -39,9 +39,9 @@ public class IntentService { @@ -39,9 +39,9 @@ public class IntentService {
39 + "⑤missing 里列出完成该意图还缺的关键信息(如 修改缺『具体记录』『新值』)。" 39 + "⑤missing 里列出完成该意图还缺的关键信息(如 修改缺『具体记录』『新值』)。"
40 + "只输出 JSON,不要解释。"; 40 + "只输出 JSON,不要解释。";
41 41
42 - private final OllamaJsonClient llm; 42 + private final LlmJsonClient llm;
43 43
44 - public IntentService(OllamaJsonClient llm) { 44 + public IntentService(LlmJsonClient llm) {
45 this.llm = llm; 45 this.llm = llm;
46 } 46 }
47 47
src/main/java/com/xly/service/OllamaJsonClient.java renamed to src/main/java/com/xly/service/LlmJsonClient.java
@@ -2,78 +2,93 @@ package com.xly.service; @@ -2,78 +2,93 @@ package com.xly.service;
2 2
3 import com.fasterxml.jackson.databind.JsonNode; 3 import com.fasterxml.jackson.databind.JsonNode;
4 import com.fasterxml.jackson.databind.ObjectMapper; 4 import com.fasterxml.jackson.databind.ObjectMapper;
  5 +import com.xly.config.TracingChatModelListener;
5 import com.xly.util.OkHttpUtil; 6 import com.xly.util.OkHttpUtil;
6 import org.slf4j.Logger; 7 import org.slf4j.Logger;
7 import org.slf4j.LoggerFactory; 8 import org.slf4j.LoggerFactory;
8 import org.springframework.beans.factory.annotation.Value; 9 import org.springframework.beans.factory.annotation.Value;
9 import org.springframework.stereotype.Service; 10 import org.springframework.stereotype.Service;
10 11
  12 +import java.time.Instant;
11 import java.util.LinkedHashMap; 13 import java.util.LinkedHashMap;
12 import java.util.List; 14 import java.util.List;
13 import java.util.Map; 15 import java.util.Map;
14 16
15 /** 17 /**
16 - * 直连 Ollama /api/chat 的**受约束 JSON**补全(constrained decoding)。 18 + * OpenAI 兼容协议(/v1/chat/completions)的**受约束 JSON**补全(constrained decoding)。
17 * 19 *
18 - * <p>为什么绕开 LangChain4j:Ollama 的 {@code format=<JSON Schema>} 会用 XGrammar 做语法约束解码,  
19 - * 保证输出**一定**是 schema 合法的 JSON(违反 schema 的 token 概率直接置 0)——这是根治「把产品名塞进客户字段」  
20 - * 这类**槽位/参数幻觉**的关键手段。实测 qwen3:14b 在此模式下意图/实体抽取 8/8 正确、~2-3s/次(think=false)。 20 + * <p>用 {@code response_format={type:"json_schema", json_schema:{schema}}} 做语法约束解码,
  21 + * 保证输出**一定**是 schema 合法的 JSON(Ollama 侧由 XGrammar 实现,违反 schema 的 token 概率直接置 0)——
  22 + * 这是根治「把产品名塞进客户字段」这类**槽位/参数幻觉**的关键手段。实测 qwen3:14b 在此模式下
  23 + * 意图/实体抽取 8/8 正确、~2-3s/次(reasoning_effort=none)。
21 * 24 *
22 - * <p>用于两处「窄而稳」的推理子任务:{@link IntentService}(意图+实体分类)与  
23 - * {@link SlotFillService}(按表单真实字段做受约束的槽位填充)。主对话/查询仍走 LangChain4j 工具循环。 25 + * <p>用于两处「窄而稳」的推理子任务:{@link IntentService}(意图+实体分类)与受约束槽位填充。
  26 + * 主对话/查询走 LangChain4j 工具循环。
24 */ 27 */
25 @Service 28 @Service
26 -public class OllamaJsonClient { 29 +public class LlmJsonClient {
27 30
28 - private static final Logger log = LoggerFactory.getLogger(OllamaJsonClient.class); 31 + private static final Logger log = LoggerFactory.getLogger(LlmJsonClient.class);
29 32
30 private final ObjectMapper mapper; 33 private final ObjectMapper mapper;
  34 + private final TracingChatModelListener tracing;
  35 + private final OkHttpUtil http = OkHttpUtil.getInstance(10, 120, 30);
31 36
32 - @Value("${langchain4j.ollama.base-url}") 37 + @Value("${llm.base-url}")
33 private String baseUrl; 38 private String baseUrl;
34 39
35 - @Value("${langchain4j.ollama.chat-model-name}") 40 + @Value("${llm.api-key:ollama}")
  41 + private String apiKey;
  42 +
  43 + @Value("${llm.chat-model}")
36 private String model; 44 private String model;
37 45
38 - public OllamaJsonClient(ObjectMapper mapper) { 46 + public LlmJsonClient(ObjectMapper mapper, TracingChatModelListener tracing) {
39 this.mapper = mapper; 47 this.mapper = mapper;
  48 + this.tracing = tracing;
40 } 49 }
41 50
42 /** 51 /**
43 - * 受约束 JSON 补全:think=false(快且稳定,实测无精度损失)、低温度、format=schema、非流式。 52 + * 受约束 JSON 补全:低温度、response_format=json_schema、非流式。
44 * 53 *
45 * @param system 系统提示(角色 + 抽取规则) 54 * @param system 系统提示(角色 + 抽取规则)
46 * @param user 用户话 55 * @param user 用户话
47 - * @param schema JSON Schema(Map 结构,直接序列化进 format 56 + * @param schema JSON Schema(Map 结构,序列化进 response_format.json_schema.schema
48 * @return 解析后的 JsonNode;失败返回 null(调用方须降级处理,绝不因它中断主流程) 57 * @return 解析后的 JsonNode;失败返回 null(调用方须降级处理,绝不因它中断主流程)
49 */ 58 */
50 public JsonNode completeJson(String system, String user, Map<String, Object> schema) { 59 public JsonNode completeJson(String system, String user, Map<String, Object> schema) {
  60 + long t0 = System.nanoTime();
  61 + String startTs = Instant.now().toString();
51 try { 62 try {
52 Map<String, Object> body = new LinkedHashMap<>(); 63 Map<String, Object> body = new LinkedHashMap<>();
53 body.put("model", model); 64 body.put("model", model);
54 body.put("stream", false); 65 body.put("stream", false);
55 - body.put("think", false);  
56 - body.put("format", schema); 66 + body.put("temperature", 0.1);
  67 + body.put("top_p", 0.9);
  68 + body.put("reasoning_effort", "none");
  69 + body.put("response_format", Map.of(
  70 + "type", "json_schema",
  71 + "json_schema", Map.of("name", "output", "schema", schema)));
57 body.put("messages", List.of( 72 body.put("messages", List.of(
58 Map.of("role", "system", "content", system), 73 Map.of("role", "system", "content", system),
59 Map.of("role", "user", "content", user))); 74 Map.of("role", "user", "content", user)));
60 - Map<String, Object> opts = new LinkedHashMap<>();  
61 - opts.put("temperature", 0.1);  
62 - opts.put("top_p", 0.9);  
63 - opts.put("num_ctx", 8192); // 足够容纳字段清单/历史,避免长上下文时工具/JSON 退化  
64 - body.put("options", opts);  
65 75
66 String json = mapper.writeValueAsString(body); 76 String json = mapper.writeValueAsString(body);
67 - String resp = OkHttpUtil.getInstance(10, 120, 30).postJson(baseUrl + "/api/chat", json); 77 + String resp = http.postJson(baseUrl + "/chat/completions", apiKey, json);
68 JsonNode root = mapper.readTree(resp); 78 JsonNode root = mapper.readTree(resp);
69 - String content = root.path("message").path("content").asText(""); 79 + JsonNode usage = root.path("usage");
  80 + tracing.record(model, t0, startTs,
  81 + usage.path("prompt_tokens").isNumber() ? usage.path("prompt_tokens").asInt() : null,
  82 + usage.path("completion_tokens").isNumber() ? usage.path("completion_tokens").asInt() : null,
  83 + null);
  84 + String content = root.path("choices").path(0).path("message").path("content").asText("");
70 if (content.isBlank()) { 85 if (content.isBlank()) {
71 - log.warn("ollama json completion: empty content"); 86 + log.warn("llm json completion: empty content");
72 return null; 87 return null;
73 } 88 }
74 return mapper.readTree(content); 89 return mapper.readTree(content);
75 } catch (Exception e) { 90 } catch (Exception e) {
76 - log.warn("ollama json completion failed: {}", e.getMessage()); 91 + tracing.record(model, t0, startTs, null, null, e.getMessage());
77 return null; 92 return null;
78 } 93 }
79 } 94 }
src/main/java/com/xly/tool/QueryTool.java
@@ -5,7 +5,7 @@ import com.xly.service.AuditService; @@ -5,7 +5,7 @@ import com.xly.service.AuditService;
5 import com.xly.service.FormResolverService; 5 import com.xly.service.FormResolverService;
6 import dev.langchain4j.agent.tool.P; 6 import dev.langchain4j.agent.tool.P;
7 import dev.langchain4j.agent.tool.Tool; 7 import dev.langchain4j.agent.tool.Tool;
8 -import dev.langchain4j.model.ollama.OllamaChatModel; 8 +import dev.langchain4j.model.chat.ChatModel;
9 import net.sf.jsqlparser.expression.StringValue; 9 import net.sf.jsqlparser.expression.StringValue;
10 import net.sf.jsqlparser.expression.operators.conditional.AndExpression; 10 import net.sf.jsqlparser.expression.operators.conditional.AndExpression;
11 import net.sf.jsqlparser.expression.operators.relational.EqualsTo; 11 import net.sf.jsqlparser.expression.operators.relational.EqualsTo;
@@ -41,13 +41,13 @@ import java.util.regex.Pattern; @@ -41,13 +41,13 @@ import java.util.regex.Pattern;
41 */ 41 */
42 public class QueryTool { 42 public class QueryTool {
43 43
44 - private final OllamaChatModel sqlModel; 44 + private final ChatModel sqlModel;
45 private final JdbcTemplate jdbc; 45 private final JdbcTemplate jdbc;
46 private final AuditService audit; 46 private final AuditService audit;
47 private final AgentIdentity identity; 47 private final AgentIdentity identity;
48 private final FormResolverService resolver; 48 private final FormResolverService resolver;
49 49
50 - public QueryTool(OllamaChatModel sqlModel, JdbcTemplate jdbc, AuditService audit, 50 + public QueryTool(ChatModel sqlModel, JdbcTemplate jdbc, AuditService audit,
51 AgentIdentity identity, FormResolverService resolver) { 51 AgentIdentity identity, FormResolverService resolver) {
52 this.sqlModel = sqlModel; 52 this.sqlModel = sqlModel;
53 this.jdbc = jdbc; 53 this.jdbc = jdbc;
src/main/java/com/xly/util/OkHttpUtil.java
@@ -6,7 +6,7 @@ import java.io.IOException; @@ -6,7 +6,7 @@ import java.io.IOException;
6 import java.util.concurrent.TimeUnit; 6 import java.util.concurrent.TimeUnit;
7 7
8 /** 8 /**
9 - * OkHttp 薄封装:目前仅 {@code OllamaJsonClient} 用它同步 POST JSON。 9 + * OkHttp 薄封装:目前仅 {@code LlmJsonClient} 用它同步 POST JSON。
10 */ 10 */
11 public class OkHttpUtil { 11 public class OkHttpUtil {
12 12
@@ -24,9 +24,14 @@ public class OkHttpUtil { @@ -24,9 +24,14 @@ public class OkHttpUtil {
24 return new OkHttpUtil(connectTimeout, readTimeout, writeTimeout); 24 return new OkHttpUtil(connectTimeout, readTimeout, writeTimeout);
25 } 25 }
26 26
27 - public String postJson(String url, String json) throws IOException { 27 + /** POST JSON,带 Bearer 鉴权(OpenAI 兼容端点;Ollama 忽略该头,云端网关需要真实 key)。 */
  28 + public String postJson(String url, String bearerToken, String json) throws IOException {
28 RequestBody body = RequestBody.create(json, MediaType.parse("application/json; charset=utf-8")); 29 RequestBody body = RequestBody.create(json, MediaType.parse("application/json; charset=utf-8"));
29 - Request request = new Request.Builder().url(url).post(body).build(); 30 + Request.Builder reqBuilder = new Request.Builder().url(url).post(body);
  31 + if (bearerToken != null && !bearerToken.isBlank()) {
  32 + reqBuilder.header("Authorization", "Bearer " + bearerToken);
  33 + }
  34 + Request request = reqBuilder.build();
30 try (Response response = client.newCall(request).execute()) { 35 try (Response response = client.newCall(request).execute()) {
31 if (!response.isSuccessful()) { 36 if (!response.isSuccessful()) {
32 throw new IOException("Unexpected code: " + response.code() + ", message: " + response.message()); 37 throw new IOException("Unexpected code: " + response.code() + ", message: " + response.message());
src/main/resources/application-saaslocal.yml
@@ -21,12 +21,6 @@ spring: @@ -21,12 +21,6 @@ spring:
21 # same local DB. The committed application.yml erp.baseurl targets the remote deploy. 21 # same local DB. The committed application.yml erp.baseurl targets the remote deploy.
22 # dev-login lets xlyAi mint a working ERP session locally (admin/666666, brand 1111111111); 22 # dev-login lets xlyAi mint a working ERP session locally (admin/666666, brand 1111111111);
23 # production should instead pass through the user's own browser ERP token per request. 23 # production should instead pass through the user's own browser ERP token per request.
24 -langchain4j:  
25 - ollama:  
26 - # qwen3:14b follows instructions better and keeps its chain-of-thought on a  
27 - # separate channel (Ollama "thinking"), so it doesn't leak into the streamed content.  
28 - chat-model-name: qwen3:14b  
29 -  
30 erp: 24 erp:
31 baseurl: http://127.0.0.1:8697/xlyEntry 25 baseurl: http://127.0.0.1:8697/xlyEntry
32 dev-login: 26 dev-login:
src/main/resources/application.yml
@@ -61,13 +61,13 @@ management: @@ -61,13 +61,13 @@ management:
61 health: 61 health:
62 show-details: always 62 show-details: always
63 63
64 -langchain4j:  
65 - ollama:  
66 - base-url: http://112.82.245.194:41434  
67 - # 聊天模型(saaslocal 覆盖为 qwen3:14b)  
68 - chat-model-name: qwen2.5:14b  
69 - # SQL/代码模型(QueryTool NL2SQL 专用)  
70 - sql-model-name: qwen2.5-coder:7b 64 +# LLM:OpenAI 兼容协议(Ollama /v1、vLLM、云端网关皆可,换供应商只改这里)
  65 +llm:
  66 + base-url: http://112.82.245.194:41434/v1
  67 + api-key: ollama # Ollama 不校验(任意非空);云端填真实 key
  68 + chat-model: qwen3.6-27b-iq3:latest
  69 + # SQL/代码模型(QueryTool NL2SQL 专用)
  70 + sql-model: qwen3.6-27b-iq3:latest
71 71
72 erp: 72 erp:
73 baseurl: http://118.178.19.35:8080/xlyEntry_saas 73 baseurl: http://118.178.19.35:8080/xlyEntry_saas