diff --git a/pom.xml b/pom.xml index 3620757..5bb93b8 100644 --- a/pom.xml +++ b/pom.xml @@ -90,7 +90,7 @@ ${okhttp.version} - + dev.langchain4j langchain4j @@ -98,7 +98,7 @@ dev.langchain4j - langchain4j-ollama + langchain4j-open-ai ${langchain4j.version} diff --git a/src/main/java/com/xly/config/AgentConfig.java b/src/main/java/com/xly/config/AgentConfig.java index 340e5f1..92d9d4b 100644 --- a/src/main/java/com/xly/config/AgentConfig.java +++ b/src/main/java/com/xly/config/AgentConfig.java @@ -1,6 +1,7 @@ package com.xly.config; -import dev.langchain4j.model.ollama.OllamaStreamingChatModel; +import dev.langchain4j.model.chat.StreamingChatModel; +import dev.langchain4j.model.openai.OpenAiStreamingChatModel; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; @@ -10,7 +11,7 @@ import java.time.Duration; import java.util.List; /** - * agent 用的流式模型 Bean。 + * agent 用的流式模型 Bean(OpenAI 兼容协议:Ollama /v1、vLLM、云端网关皆可,换供应商只改配置)。 * *

单一 ReAct agent 的**组装**已移到 {@link AgentFactory}(按每次请求的身份新建携带 token/权限的工具实例, * 见 §5/§7 per-call context)。本类只保留全局复用的流式模型。 @@ -18,27 +19,28 @@ import java.util.List; @Configuration public class AgentConfig { - @Value("${langchain4j.ollama.base-url}") - private String ollamaUrl; + @Value("${llm.base-url}") + private String baseUrl; - @Value("${langchain4j.ollama.chat-model-name}") + @Value("${llm.api-key:ollama}") + private String apiKey; + + @Value("${llm.chat-model}") private String chatModelName; @Autowired private TracingChatModelListener tracingListener; - /** 专供 agent 的流式模型:低温度利于稳定的工具调用,较大 numPredict 避免答复被截断。 */ + /** 专供 agent 的流式模型:低温度利于稳定的工具调用。 */ @Bean("agentStreamingModel") - public OllamaStreamingChatModel agentStreamingModel() { - return OllamaStreamingChatModel.builder() - .baseUrl(ollamaUrl) + public StreamingChatModel agentStreamingModel() { + return OpenAiStreamingChatModel.builder() + .baseUrl(baseUrl) + .apiKey(apiKey) .modelName(chatModelName) .temperature(0.1) .topP(0.9) - .numPredict(2048) - // qwen3 支持「思考」模式,但会显著拖慢交互;关闭它 -> 快,且思考不会混进回答 - .think(false) - .returnThinking(false) + .reasoningEffort("none") .listeners(List.of(tracingListener)) .timeout(Duration.ofSeconds(180)) .build(); diff --git a/src/main/java/com/xly/config/AgentFactory.java b/src/main/java/com/xly/config/AgentFactory.java index 00933dd..227c869 100644 --- a/src/main/java/com/xly/config/AgentFactory.java +++ b/src/main/java/com/xly/config/AgentFactory.java @@ -18,8 +18,8 @@ import com.xly.tool.ProposeWriteTool; import com.xly.tool.QueryTool; import com.xly.tool.SkillTool; import dev.langchain4j.memory.chat.MessageWindowChatMemory; -import dev.langchain4j.model.ollama.OllamaChatModel; -import dev.langchain4j.model.ollama.OllamaStreamingChatModel; +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.chat.StreamingChatModel; import dev.langchain4j.service.AiServices; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.jdbc.core.JdbcTemplate; @@ -38,8 +38,8 @@ import org.springframework.stereotype.Component; @Component public class AgentFactory { - private final OllamaStreamingChatModel streamingModel; - private final OllamaChatModel sqlModel; + private final StreamingChatModel streamingModel; + private final ChatModel sqlModel; private final RedisChatMemoryStore memoryStore; private final SystemPromptService systemPromptService; @@ -55,8 +55,8 @@ public class AgentFactory { private final SkillTool skillTool; private final InteractionTool interactionTool; - public AgentFactory(@Qualifier("agentStreamingModel") OllamaStreamingChatModel streamingModel, - @Qualifier("sqlChatModel") OllamaChatModel sqlModel, + public AgentFactory(@Qualifier("agentStreamingModel") StreamingChatModel streamingModel, + @Qualifier("sqlChatModel") ChatModel sqlModel, RedisChatMemoryStore memoryStore, SystemPromptService systemPromptService, ErpClient erp, JdbcTemplate jdbc, FormResolverService resolver, OpService ops, diff --git a/src/main/java/com/xly/config/ModelConfig.java b/src/main/java/com/xly/config/ModelConfig.java index fd994ff..3044073 100644 --- a/src/main/java/com/xly/config/ModelConfig.java +++ b/src/main/java/com/xly/config/ModelConfig.java @@ -3,32 +3,39 @@ package com.xly.config; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; -import dev.langchain4j.model.ollama.OllamaChatModel; +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.openai.OpenAiChatModel; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Primary; import java.time.Duration; +import java.util.List; @Configuration public class ModelConfig { - @Value("${langchain4j.ollama.base-url}") - private String chatModelUrl; + @Value("${llm.base-url}") + private String baseUrl; - @Value("${langchain4j.ollama.sql-model-name}") + @Value("${llm.api-key:ollama}") + private String apiKey; + + @Value("${llm.sql-model}") private String sqlModelName; - /** NL2SQL 专用模型(QueryTool 经 AgentFactory 注入)。 */ + /** NL2SQL 专用模型(QueryTool 经 AgentFactory 注入),OpenAI 兼容协议。 */ @Bean("sqlChatModel") - public OllamaChatModel sqlChatModel() { - return OllamaChatModel.builder() - .baseUrl(chatModelUrl) + public ChatModel sqlChatModel(TracingChatModelListener tracingListener) { + return OpenAiChatModel.builder() + .baseUrl(baseUrl) + .apiKey(apiKey) .modelName(sqlModelName) .temperature(0.0) .topP(0.95) - .numPredict(4096) + .reasoningEffort("none") + .listeners(List.of(tracingListener)) .timeout(Duration.ofSeconds(120)) .maxRetries(3) .build(); diff --git a/src/main/java/com/xly/config/TracingChatModelListener.java b/src/main/java/com/xly/config/TracingChatModelListener.java index 5c6c869..48147a1 100644 --- a/src/main/java/com/xly/config/TracingChatModelListener.java +++ b/src/main/java/com/xly/config/TracingChatModelListener.java @@ -48,8 +48,6 @@ public class TracingChatModelListener implements ChatModelListener { private String publicKey; @Value("${langfuse.secret-key:}") private String secretKey; - @Value("${langchain4j.ollama.chat-model-name:unknown}") - private String modelName; public TracingChatModelListener(ObjectMapper mapper) { this.mapper = mapper; @@ -59,11 +57,12 @@ public class TracingChatModelListener implements ChatModelListener { public void onRequest(ChatModelRequestContext ctx) { ctx.attributes().put("t0", System.nanoTime()); ctx.attributes().put("startTs", Instant.now().toString()); + String model = ctx.chatRequest() == null ? null : ctx.chatRequest().modelName(); + ctx.attributes().put("model", model == null ? "unknown" : model); } @Override public void onResponse(ChatModelResponseContext ctx) { - long ms = elapsedMs(ctx.attributes().get("t0")); Integer in = null; Integer out = null; try { @@ -74,20 +73,31 @@ public class TracingChatModelListener implements ChatModelListener { } } catch (Exception ignore) { } - log.info("LLM ok {}ms tokens(in/out)={}/{}", ms, in, out); - exportToLangfuse(String.valueOf(ctx.attributes().get("startTs")), in, out, null); + record(String.valueOf(ctx.attributes().get("model")), ctx.attributes().get("t0"), + String.valueOf(ctx.attributes().get("startTs")), in, out, null); } @Override public void onError(ChatModelErrorContext ctx) { Throwable e = ctx.error(); String msg = e == null ? "?" : e.getMessage(); - log.warn("LLM error: {}", msg); - exportToLangfuse(String.valueOf(ctx.attributes().get("startTs")), null, null, msg); + record(String.valueOf(ctx.attributes().get("model")), ctx.attributes().get("t0"), + String.valueOf(ctx.attributes().get("startTs")), null, null, msg); + } + + /** 统一记录一次 LLM 调用(listener 回调与 {@code LlmJsonClient} 直连路径共用)。 */ + public void record(String model, Object startNanos, String startTs, Integer in, Integer out, String error) { + long ms = elapsedMs(startNanos); + if (error == null) { + log.info("LLM ok {} {}ms tokens(in/out)={}/{}", model, ms, in, out); + } else { + log.warn("LLM error {} {}ms: {}", model, ms, error); + } + exportToLangfuse(model, startTs, in, out, error); } /** 把一条 generation span 转发到 Langfuse(best-effort,异步,失败仅告警)。未启用则直接返回。 */ - private void exportToLangfuse(String startTs, Integer in, Integer out, String error) { + private void exportToLangfuse(String modelName, String startTs, Integer in, Integer out, String error) { if (!langfuseEnabled || publicKey == null || publicKey.isBlank() || secretKey == null || secretKey.isBlank()) { return; } diff --git a/src/main/java/com/xly/service/IntentService.java b/src/main/java/com/xly/service/IntentService.java index 63ec870..8ca800a 100644 --- a/src/main/java/com/xly/service/IntentService.java +++ b/src/main/java/com/xly/service/IntentService.java @@ -13,7 +13,7 @@ import java.util.Map; /** * 第 0 阶段意图门(intent gate):把一句用户话分类为 {意图, 单据类型, 带角色的实体, 缺失信息}。 * - *

只做**一件窄任务**,用受约束 JSON 解码({@link OllamaJsonClient})。实测在此形态下 + *

只做**一件窄任务**,用受约束 JSON 解码({@link LlmJsonClient})。实测在此形态下 * qwen3:14b 对包括「报价纸盒→纸盒是产品而非客户」「给苏州华为报价彩盒→客户+产品分离」 * 「有多少个客户→查询」在内的样本 8/8 正确、~2-3s/次。相较之下,让同一个模型在全部 9 个工具的 * 单次 ReAct 里同时判意图/选工具/编参数则频繁出错(把查询错当新增、更新流程死循环等)。 @@ -39,9 +39,9 @@ public class IntentService { + "⑤missing 里列出完成该意图还缺的关键信息(如 修改缺『具体记录』『新值』)。" + "只输出 JSON,不要解释。"; - private final OllamaJsonClient llm; + private final LlmJsonClient llm; - public IntentService(OllamaJsonClient llm) { + public IntentService(LlmJsonClient llm) { this.llm = llm; } diff --git a/src/main/java/com/xly/service/OllamaJsonClient.java b/src/main/java/com/xly/service/LlmJsonClient.java index 4b9367b..276ef89 100644 --- a/src/main/java/com/xly/service/OllamaJsonClient.java +++ b/src/main/java/com/xly/service/LlmJsonClient.java @@ -2,78 +2,93 @@ package com.xly.service; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.xly.config.TracingChatModelListener; import com.xly.util.OkHttpUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import java.time.Instant; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; /** - * 直连 Ollama /api/chat 的**受约束 JSON**补全(constrained decoding)。 + * OpenAI 兼容协议(/v1/chat/completions)的**受约束 JSON**补全(constrained decoding)。 * - *

为什么绕开 LangChain4j:Ollama 的 {@code format=} 会用 XGrammar 做语法约束解码, - * 保证输出**一定**是 schema 合法的 JSON(违反 schema 的 token 概率直接置 0)——这是根治「把产品名塞进客户字段」 - * 这类**槽位/参数幻觉**的关键手段。实测 qwen3:14b 在此模式下意图/实体抽取 8/8 正确、~2-3s/次(think=false)。 + *

用 {@code response_format={type:"json_schema", json_schema:{schema}}} 做语法约束解码, + * 保证输出**一定**是 schema 合法的 JSON(Ollama 侧由 XGrammar 实现,违反 schema 的 token 概率直接置 0)—— + * 这是根治「把产品名塞进客户字段」这类**槽位/参数幻觉**的关键手段。实测 qwen3:14b 在此模式下 + * 意图/实体抽取 8/8 正确、~2-3s/次(reasoning_effort=none)。 * - *

用于两处「窄而稳」的推理子任务:{@link IntentService}(意图+实体分类)与 - * {@link SlotFillService}(按表单真实字段做受约束的槽位填充)。主对话/查询仍走 LangChain4j 工具循环。 + *

用于两处「窄而稳」的推理子任务:{@link IntentService}(意图+实体分类)与受约束槽位填充。 + * 主对话/查询走 LangChain4j 工具循环。 */ @Service -public class OllamaJsonClient { +public class LlmJsonClient { - private static final Logger log = LoggerFactory.getLogger(OllamaJsonClient.class); + private static final Logger log = LoggerFactory.getLogger(LlmJsonClient.class); private final ObjectMapper mapper; + private final TracingChatModelListener tracing; + private final OkHttpUtil http = OkHttpUtil.getInstance(10, 120, 30); - @Value("${langchain4j.ollama.base-url}") + @Value("${llm.base-url}") private String baseUrl; - @Value("${langchain4j.ollama.chat-model-name}") + @Value("${llm.api-key:ollama}") + private String apiKey; + + @Value("${llm.chat-model}") private String model; - public OllamaJsonClient(ObjectMapper mapper) { + public LlmJsonClient(ObjectMapper mapper, TracingChatModelListener tracing) { this.mapper = mapper; + this.tracing = tracing; } /** - * 受约束 JSON 补全:think=false(快且稳定,实测无精度损失)、低温度、format=schema、非流式。 + * 受约束 JSON 补全:低温度、response_format=json_schema、非流式。 * * @param system 系统提示(角色 + 抽取规则) * @param user 用户话 - * @param schema JSON Schema(Map 结构,直接序列化进 format) + * @param schema JSON Schema(Map 结构,序列化进 response_format.json_schema.schema) * @return 解析后的 JsonNode;失败返回 null(调用方须降级处理,绝不因它中断主流程) */ public JsonNode completeJson(String system, String user, Map schema) { + long t0 = System.nanoTime(); + String startTs = Instant.now().toString(); try { Map body = new LinkedHashMap<>(); body.put("model", model); body.put("stream", false); - body.put("think", false); - body.put("format", schema); + body.put("temperature", 0.1); + body.put("top_p", 0.9); + body.put("reasoning_effort", "none"); + body.put("response_format", Map.of( + "type", "json_schema", + "json_schema", Map.of("name", "output", "schema", schema))); body.put("messages", List.of( Map.of("role", "system", "content", system), Map.of("role", "user", "content", user))); - Map opts = new LinkedHashMap<>(); - opts.put("temperature", 0.1); - opts.put("top_p", 0.9); - opts.put("num_ctx", 8192); // 足够容纳字段清单/历史,避免长上下文时工具/JSON 退化 - body.put("options", opts); String json = mapper.writeValueAsString(body); - String resp = OkHttpUtil.getInstance(10, 120, 30).postJson(baseUrl + "/api/chat", json); + String resp = http.postJson(baseUrl + "/chat/completions", apiKey, json); JsonNode root = mapper.readTree(resp); - String content = root.path("message").path("content").asText(""); + JsonNode usage = root.path("usage"); + tracing.record(model, t0, startTs, + usage.path("prompt_tokens").isNumber() ? usage.path("prompt_tokens").asInt() : null, + usage.path("completion_tokens").isNumber() ? usage.path("completion_tokens").asInt() : null, + null); + String content = root.path("choices").path(0).path("message").path("content").asText(""); if (content.isBlank()) { - log.warn("ollama json completion: empty content"); + log.warn("llm json completion: empty content"); return null; } return mapper.readTree(content); } catch (Exception e) { - log.warn("ollama json completion failed: {}", e.getMessage()); + tracing.record(model, t0, startTs, null, null, e.getMessage()); return null; } } diff --git a/src/main/java/com/xly/tool/QueryTool.java b/src/main/java/com/xly/tool/QueryTool.java index 0615815..28a8ae0 100644 --- a/src/main/java/com/xly/tool/QueryTool.java +++ b/src/main/java/com/xly/tool/QueryTool.java @@ -5,7 +5,7 @@ import com.xly.service.AuditService; import com.xly.service.FormResolverService; import dev.langchain4j.agent.tool.P; import dev.langchain4j.agent.tool.Tool; -import dev.langchain4j.model.ollama.OllamaChatModel; +import dev.langchain4j.model.chat.ChatModel; import net.sf.jsqlparser.expression.StringValue; import net.sf.jsqlparser.expression.operators.conditional.AndExpression; import net.sf.jsqlparser.expression.operators.relational.EqualsTo; @@ -41,13 +41,13 @@ import java.util.regex.Pattern; */ public class QueryTool { - private final OllamaChatModel sqlModel; + private final ChatModel sqlModel; private final JdbcTemplate jdbc; private final AuditService audit; private final AgentIdentity identity; private final FormResolverService resolver; - public QueryTool(OllamaChatModel sqlModel, JdbcTemplate jdbc, AuditService audit, + public QueryTool(ChatModel sqlModel, JdbcTemplate jdbc, AuditService audit, AgentIdentity identity, FormResolverService resolver) { this.sqlModel = sqlModel; this.jdbc = jdbc; diff --git a/src/main/java/com/xly/util/OkHttpUtil.java b/src/main/java/com/xly/util/OkHttpUtil.java index 5c0e344..b1d5d32 100644 --- a/src/main/java/com/xly/util/OkHttpUtil.java +++ b/src/main/java/com/xly/util/OkHttpUtil.java @@ -6,7 +6,7 @@ import java.io.IOException; import java.util.concurrent.TimeUnit; /** - * OkHttp 薄封装:目前仅 {@code OllamaJsonClient} 用它同步 POST JSON。 + * OkHttp 薄封装:目前仅 {@code LlmJsonClient} 用它同步 POST JSON。 */ public class OkHttpUtil { @@ -24,9 +24,14 @@ public class OkHttpUtil { return new OkHttpUtil(connectTimeout, readTimeout, writeTimeout); } - public String postJson(String url, String json) throws IOException { + /** POST JSON,带 Bearer 鉴权(OpenAI 兼容端点;Ollama 忽略该头,云端网关需要真实 key)。 */ + public String postJson(String url, String bearerToken, String json) throws IOException { RequestBody body = RequestBody.create(json, MediaType.parse("application/json; charset=utf-8")); - Request request = new Request.Builder().url(url).post(body).build(); + Request.Builder reqBuilder = new Request.Builder().url(url).post(body); + if (bearerToken != null && !bearerToken.isBlank()) { + reqBuilder.header("Authorization", "Bearer " + bearerToken); + } + Request request = reqBuilder.build(); try (Response response = client.newCall(request).execute()) { if (!response.isSuccessful()) { throw new IOException("Unexpected code: " + response.code() + ", message: " + response.message()); diff --git a/src/main/resources/application-saaslocal.yml b/src/main/resources/application-saaslocal.yml index 5f4589a..fb41560 100644 --- a/src/main/resources/application-saaslocal.yml +++ b/src/main/resources/application-saaslocal.yml @@ -21,12 +21,6 @@ spring: # same local DB. The committed application.yml erp.baseurl targets the remote deploy. # dev-login lets xlyAi mint a working ERP session locally (admin/666666, brand 1111111111); # production should instead pass through the user's own browser ERP token per request. -langchain4j: - ollama: - # qwen3:14b follows instructions better and keeps its chain-of-thought on a - # separate channel (Ollama "thinking"), so it doesn't leak into the streamed content. - chat-model-name: qwen3:14b - erp: baseurl: http://127.0.0.1:8697/xlyEntry dev-login: diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 64e96a9..c0096f2 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -61,13 +61,13 @@ management: health: show-details: always -langchain4j: - ollama: - base-url: http://112.82.245.194:41434 - # 聊天模型(saaslocal 覆盖为 qwen3:14b) - chat-model-name: qwen2.5:14b - # SQL/代码模型(QueryTool NL2SQL 专用) - sql-model-name: qwen2.5-coder:7b +# LLM:OpenAI 兼容协议(Ollama /v1、vLLM、云端网关皆可,换供应商只改这里) +llm: + base-url: http://112.82.245.194:41434/v1 + api-key: ollama # Ollama 不校验(任意非空);云端填真实 key + chat-model: qwen3.6-27b-iq3:latest + # SQL/代码模型(QueryTool NL2SQL 专用) + sql-model: qwen3.6-27b-iq3:latest erp: baseurl: http://118.178.19.35:8080/xlyEntry_saas