Commit 7b4fbe386a2d48fa9a9c1e6d9079124219068fe0
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).
Showing
11 changed files
with
117 additions
and
84 deletions
pom.xml
| @@ -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 |