LlmJsonClient.java
3.94 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
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;
}
}
}