diff --git a/pom.xml b/pom.xml
index 3620757..5bb93b8 100644
--- a/pom.xml
+++ b/pom.xml
@@ -90,7 +90,7 @@
单一 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= 用 {@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