package com.xly.service; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; 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.util.LinkedHashMap; import java.util.List; import java.util.Map; /** * 直连 Ollama /api/chat 的**受约束 JSON**补全(constrained decoding)。 * *

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

用于两处「窄而稳」的推理子任务:{@link IntentService}(意图+实体分类)与 * {@link SlotFillService}(按表单真实字段做受约束的槽位填充)。主对话/查询仍走 LangChain4j 工具循环。 */ @Service public class OllamaJsonClient { private static final Logger log = LoggerFactory.getLogger(OllamaJsonClient.class); private final ObjectMapper mapper; @Value("${langchain4j.ollama.base-url}") private String baseUrl; @Value("${langchain4j.ollama.chat-model-name}") private String model; public OllamaJsonClient(ObjectMapper mapper) { this.mapper = mapper; } /** * 受约束 JSON 补全:think=false(快且稳定,实测无精度损失)、低温度、format=schema、非流式。 * * @param system 系统提示(角色 + 抽取规则) * @param user 用户话 * @param schema JSON Schema(Map 结构,直接序列化进 format) * @return 解析后的 JsonNode;失败返回 null(调用方须降级处理,绝不因它中断主流程) */ public JsonNode completeJson(String system, String user, Map schema) { try { Map body = new LinkedHashMap<>(); body.put("model", model); body.put("stream", false); body.put("think", false); body.put("format", 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); JsonNode root = mapper.readTree(resp); String content = root.path("message").path("content").asText(""); if (content.isBlank()) { log.warn("ollama json completion: empty content"); return null; } return mapper.readTree(content); } catch (Exception e) { log.warn("ollama json completion failed: {}", e.getMessage()); return null; } } }