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 90 <version>${okhttp.version}</version>
91 91 </dependency>
92 92  
93   - <!-- LangChain4j 核心 + Ollama -->
  93 + <!-- LangChain4j 核心 + OpenAI 兼容协议(Ollama /v1、云端 API 皆可,走配置) -->
94 94 <dependency>
95 95 <groupId>dev.langchain4j</groupId>
96 96 <artifactId>langchain4j</artifactId>
... ... @@ -98,7 +98,7 @@
98 98 </dependency>
99 99 <dependency>
100 100 <groupId>dev.langchain4j</groupId>
101   - <artifactId>langchain4j-ollama</artifactId>
  101 + <artifactId>langchain4j-open-ai</artifactId>
102 102 <version>${langchain4j.version}</version>
103 103 </dependency>
104 104  
... ...
src/main/java/com/xly/config/AgentConfig.java
1 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 5 import org.springframework.beans.factory.annotation.Autowired;
5 6 import org.springframework.beans.factory.annotation.Value;
6 7 import org.springframework.context.annotation.Bean;
... ... @@ -10,7 +11,7 @@ import java.time.Duration;
10 11 import java.util.List;
11 12  
12 13 /**
13   - * agent 用的流式模型 Bean
  14 + * agent 用的流式模型 Bean(OpenAI 兼容协议:Ollama /v1、vLLM、云端网关皆可,换供应商只改配置)
14 15 *
15 16 * <p>单一 ReAct agent 的**组装**已移到 {@link AgentFactory}(按每次请求的身份新建携带 token/权限的工具实例,
16 17 * 见 §5/§7 per-call context)。本类只保留全局复用的流式模型。
... ... @@ -18,27 +19,28 @@ import java.util.List;
18 19 @Configuration
19 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 29 private String chatModelName;
26 30  
27 31 @Autowired
28 32 private TracingChatModelListener tracingListener;
29 33  
30   - /** 专供 agent 的流式模型:低温度利于稳定的工具调用,较大 numPredict 避免答复被截断。 */
  34 + /** 专供 agent 的流式模型:低温度利于稳定的工具调用。 */
31 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 40 .modelName(chatModelName)
36 41 .temperature(0.1)
37 42 .topP(0.9)
38   - .numPredict(2048)
39   - // qwen3 支持「思考」模式,但会显著拖慢交互;关闭它 -> 快,且思考不会混进回答
40   - .think(false)
41   - .returnThinking(false)
  43 + .reasoningEffort("none")
42 44 .listeners(List.of(tracingListener))
43 45 .timeout(Duration.ofSeconds(180))
44 46 .build();
... ...
src/main/java/com/xly/config/AgentFactory.java
... ... @@ -18,8 +18,8 @@ import com.xly.tool.ProposeWriteTool;
18 18 import com.xly.tool.QueryTool;
19 19 import com.xly.tool.SkillTool;
20 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 23 import dev.langchain4j.service.AiServices;
24 24 import org.springframework.beans.factory.annotation.Qualifier;
25 25 import org.springframework.jdbc.core.JdbcTemplate;
... ... @@ -38,8 +38,8 @@ import org.springframework.stereotype.Component;
38 38 @Component
39 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 43 private final RedisChatMemoryStore memoryStore;
44 44 private final SystemPromptService systemPromptService;
45 45  
... ... @@ -55,8 +55,8 @@ public class AgentFactory {
55 55 private final SkillTool skillTool;
56 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 60 RedisChatMemoryStore memoryStore,
61 61 SystemPromptService systemPromptService,
62 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 3 import com.fasterxml.jackson.databind.ObjectMapper;
4 4 import com.fasterxml.jackson.databind.SerializationFeature;
5 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 8 import org.springframework.beans.factory.annotation.Value;
8 9 import org.springframework.context.annotation.Bean;
9 10 import org.springframework.context.annotation.Configuration;
10 11 import org.springframework.context.annotation.Primary;
11 12  
12 13 import java.time.Duration;
  14 +import java.util.List;
13 15  
14 16 @Configuration
15 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 26 private String sqlModelName;
22 27  
23   - /** NL2SQL 专用模型(QueryTool 经 AgentFactory 注入)。 */
  28 + /** NL2SQL 专用模型(QueryTool 经 AgentFactory 注入),OpenAI 兼容协议。 */
24 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 34 .modelName(sqlModelName)
29 35 .temperature(0.0)
30 36 .topP(0.95)
31   - .numPredict(4096)
  37 + .reasoningEffort("none")
  38 + .listeners(List.of(tracingListener))
32 39 .timeout(Duration.ofSeconds(120))
33 40 .maxRetries(3)
34 41 .build();
... ...
src/main/java/com/xly/config/TracingChatModelListener.java
... ... @@ -48,8 +48,6 @@ public class TracingChatModelListener implements ChatModelListener {
48 48 private String publicKey;
49 49 @Value("${langfuse.secret-key:}")
50 50 private String secretKey;
51   - @Value("${langchain4j.ollama.chat-model-name:unknown}")
52   - private String modelName;
53 51  
54 52 public TracingChatModelListener(ObjectMapper mapper) {
55 53 this.mapper = mapper;
... ... @@ -59,11 +57,12 @@ public class TracingChatModelListener implements ChatModelListener {
59 57 public void onRequest(ChatModelRequestContext ctx) {
60 58 ctx.attributes().put("t0", System.nanoTime());
61 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 64 @Override
65 65 public void onResponse(ChatModelResponseContext ctx) {
66   - long ms = elapsedMs(ctx.attributes().get("t0"));
67 66 Integer in = null;
68 67 Integer out = null;
69 68 try {
... ... @@ -74,20 +73,31 @@ public class TracingChatModelListener implements ChatModelListener {
74 73 }
75 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 80 @Override
82 81 public void onError(ChatModelErrorContext ctx) {
83 82 Throwable e = ctx.error();
84 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 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 101 if (!langfuseEnabled || publicKey == null || publicKey.isBlank() || secretKey == null || secretKey.isBlank()) {
92 102 return;
93 103 }
... ...
src/main/java/com/xly/service/IntentService.java
... ... @@ -13,7 +13,7 @@ import java.util.Map;
13 13 /**
14 14 * 第 0 阶段意图门(intent gate):把一句用户话分类为 {意图, 单据类型, 带角色的实体, 缺失信息}。
15 15 *
16   - * <p>只做**一件窄任务**,用受约束 JSON 解码({@link OllamaJsonClient})。实测在此形态下
  16 + * <p>只做**一件窄任务**,用受约束 JSON 解码({@link LlmJsonClient})。实测在此形态下
17 17 * qwen3:14b 对包括「报价纸盒→纸盒是产品而非客户」「给苏州华为报价彩盒→客户+产品分离」
18 18 * 「有多少个客户→查询」在内的样本 8/8 正确、~2-3s/次。相较之下,让同一个模型在全部 9 个工具的
19 19 * 单次 ReAct 里同时判意图/选工具/编参数则频繁出错(把查询错当新增、更新流程死循环等)。
... ... @@ -39,9 +39,9 @@ public class IntentService {
39 39 + "⑤missing 里列出完成该意图还缺的关键信息(如 修改缺『具体记录』『新值』)。"
40 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 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 2  
3 3 import com.fasterxml.jackson.databind.JsonNode;
4 4 import com.fasterxml.jackson.databind.ObjectMapper;
  5 +import com.xly.config.TracingChatModelListener;
5 6 import com.xly.util.OkHttpUtil;
6 7 import org.slf4j.Logger;
7 8 import org.slf4j.LoggerFactory;
8 9 import org.springframework.beans.factory.annotation.Value;
9 10 import org.springframework.stereotype.Service;
10 11  
  12 +import java.time.Instant;
11 13 import java.util.LinkedHashMap;
12 14 import java.util.List;
13 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 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 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 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 44 private String model;
37 45  
38   - public OllamaJsonClient(ObjectMapper mapper) {
  46 + public LlmJsonClient(ObjectMapper mapper, TracingChatModelListener tracing) {
39 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 54 * @param system 系统提示(角色 + 抽取规则)
46 55 * @param user 用户话
47   - * @param schema JSON Schema(Map 结构,直接序列化进 format
  56 + * @param schema JSON Schema(Map 结构,序列化进 response_format.json_schema.schema
48 57 * @return 解析后的 JsonNode;失败返回 null(调用方须降级处理,绝不因它中断主流程)
49 58 */
50 59 public JsonNode completeJson(String system, String user, Map<String, Object> schema) {
  60 + long t0 = System.nanoTime();
  61 + String startTs = Instant.now().toString();
51 62 try {
52 63 Map<String, Object> body = new LinkedHashMap<>();
53 64 body.put("model", model);
54 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 72 body.put("messages", List.of(
58 73 Map.of("role", "system", "content", system),
59 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 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 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 85 if (content.isBlank()) {
71   - log.warn("ollama json completion: empty content");
  86 + log.warn("llm json completion: empty content");
72 87 return null;
73 88 }
74 89 return mapper.readTree(content);
75 90 } catch (Exception e) {
76   - log.warn("ollama json completion failed: {}", e.getMessage());
  91 + tracing.record(model, t0, startTs, null, null, e.getMessage());
77 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 5 import com.xly.service.FormResolverService;
6 6 import dev.langchain4j.agent.tool.P;
7 7 import dev.langchain4j.agent.tool.Tool;
8   -import dev.langchain4j.model.ollama.OllamaChatModel;
  8 +import dev.langchain4j.model.chat.ChatModel;
9 9 import net.sf.jsqlparser.expression.StringValue;
10 10 import net.sf.jsqlparser.expression.operators.conditional.AndExpression;
11 11 import net.sf.jsqlparser.expression.operators.relational.EqualsTo;
... ... @@ -41,13 +41,13 @@ import java.util.regex.Pattern;
41 41 */
42 42 public class QueryTool {
43 43  
44   - private final OllamaChatModel sqlModel;
  44 + private final ChatModel sqlModel;
45 45 private final JdbcTemplate jdbc;
46 46 private final AuditService audit;
47 47 private final AgentIdentity identity;
48 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 51 AgentIdentity identity, FormResolverService resolver) {
52 52 this.sqlModel = sqlModel;
53 53 this.jdbc = jdbc;
... ...
src/main/java/com/xly/util/OkHttpUtil.java
... ... @@ -6,7 +6,7 @@ import java.io.IOException;
6 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 11 public class OkHttpUtil {
12 12  
... ... @@ -24,9 +24,14 @@ public class OkHttpUtil {
24 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 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 35 try (Response response = client.newCall(request).execute()) {
31 36 if (!response.isSuccessful()) {
32 37 throw new IOException("Unexpected code: " + response.code() + ", message: " + response.message());
... ...
src/main/resources/application-saaslocal.yml
... ... @@ -21,12 +21,6 @@ spring:
21 21 # same local DB. The committed application.yml erp.baseurl targets the remote deploy.
22 22 # dev-login lets xlyAi mint a working ERP session locally (admin/666666, brand 1111111111);
23 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 24 erp:
31 25 baseurl: http://127.0.0.1:8697/xlyEntry
32 26 dev-login:
... ...
src/main/resources/application.yml
... ... @@ -61,13 +61,13 @@ management:
61 61 health:
62 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 72 erp:
73 73 baseurl: http://118.178.19.35:8080/xlyEntry_saas
... ...