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; /** * OpenAI 兼容协议(/v1/chat/completions)的**受约束 JSON**补全(constrained decoding)。 * *

用 {@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}(意图+实体分类)与受约束槽位填充。 * 主对话/查询走 LangChain4j 工具循环。 */ @Service public class LlmJsonClient { 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("${llm.base-url}") private String baseUrl; @Value("${llm.api-key:ollama}") private String apiKey; @Value("${llm.chat-model}") private String model; public LlmJsonClient(ObjectMapper mapper, TracingChatModelListener tracing) { this.mapper = mapper; this.tracing = tracing; } /** * 受约束 JSON 补全:低温度、response_format=json_schema、非流式。 * * @param system 系统提示(角色 + 抽取规则) * @param user 用户话 * @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("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))); String json = mapper.writeValueAsString(body); String resp = http.postJson(baseUrl + "/chat/completions", apiKey, json); JsonNode root = mapper.readTree(resp); 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("llm json completion: empty content"); return null; } return mapper.readTree(content); } catch (Exception e) { tracing.record(model, t0, startTs, null, null, e.getMessage()); return null; } } }