LlmJsonClient.java 3.94 KB
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)。
 *
 * <p>用 {@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)。
 *
 * <p>用于两处「窄而稳」的推理子任务:{@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<String, Object> schema) {
        long t0 = System.nanoTime();
        String startTs = Instant.now().toString();
        try {
            Map<String, Object> 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;
        }
    }
}