Commit ec58ec474bfe48fefb295d140da4f1354328fa4b

Authored by zichun
1 parent fec84ce8

A: ai_chat_event MySQL ledger (append-only, iSeq/iTurn, Redis hot cache) + token…

…-budget projection (conservative estimator, in-turn demotion, pre-send self-check, prompt_eval_count calibration)
sql/ai_chat_event.sql 0 → 100644
  1 +-- ai_chat_event:会话事件账本(唯一事实源,append-only,永不删除)。
  2 +-- 每事件一行:一句话 / 一次工具调用 / 一次工具结果 / 一次按钮点击,比"轮"更细。
  3 +-- 字段按 ERP 惯例命名(sMakePerson/tCreateDate/sBrandsId/sSubsidiaryId)。
  4 +-- sPayload = 事件 JSON 全文(含 t/type 与业务字段,与 Redis 热缓存同格式)。
  5 +-- iSeq = 会话内序号 1..n(服务端赋值,UNIQUE 防并发撞号;纯溯源列,不进投影/API)。
  6 +-- iTurn = 轮次 1..m(user/form_submit 开新轮,其余事件继承当前轮;纯溯源列)。
  7 +-- Redis(chat:ledger:{convId},30 天 TTL)只是热缓存:读优先走 Redis,缓存失效回源本表。
  8 +CREATE TABLE IF NOT EXISTS ai_chat_event (
  9 + iId bigint NOT NULL AUTO_INCREMENT PRIMARY KEY,
  10 + sConversationId varchar(96) NOT NULL,
  11 + iSeq int NOT NULL,
  12 + iTurn int NOT NULL DEFAULT 1,
  13 + sMakePerson varchar(64) NULL,
  14 + sBrandsId varchar(32) NULL,
  15 + sSubsidiaryId varchar(32) NULL,
  16 + sType varchar(32) NOT NULL,
  17 + sPayload mediumtext,
  18 + tCreateDate datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  19 + UNIQUE KEY uk_conv_seq (sConversationId, iSeq),
  20 + KEY idx_conv (sConversationId, iId)
  21 +);
... ...
src/main/java/com/xly/agent/AgentIdentity.java
... ... @@ -18,12 +18,15 @@ public final class AgentIdentity {
18 18 private final String token;
19 19 private final String userId;
20 20 private final String brandsId;
  21 + private final String subsidiaryId;
21 22 private final Set<String> grantedModuleIds; // null = 全部(管理员)
22 23  
23   - public AgentIdentity(String token, String userId, String brandsId, Set<String> grantedModuleIds) {
  24 + public AgentIdentity(String token, String userId, String brandsId, String subsidiaryId,
  25 + Set<String> grantedModuleIds) {
24 26 this.token = token;
25 27 this.userId = userId;
26 28 this.brandsId = brandsId;
  29 + this.subsidiaryId = subsidiaryId;
27 30 this.grantedModuleIds = grantedModuleIds;
28 31 }
29 32  
... ... @@ -40,6 +43,10 @@ public final class AgentIdentity {
40 43 return brandsId;
41 44 }
42 45  
  46 + public String subsidiaryId() {
  47 + return subsidiaryId;
  48 + }
  49 +
43 50 public boolean canAccessModule(String moduleId) {
44 51 return grantedModuleIds == null || (moduleId != null && grantedModuleIds.contains(moduleId));
45 52 }
... ...
src/main/java/com/xly/agent/EventLogChatMemory.java
... ... @@ -5,7 +5,6 @@ import com.xly.service.LedgerService;
5 5 import dev.langchain4j.data.message.AiMessage;
6 6 import dev.langchain4j.data.message.ChatMessage;
7 7 import dev.langchain4j.agent.tool.ToolExecutionRequest;
8   -import dev.langchain4j.data.message.ChatMessageSerializer;
9 8 import dev.langchain4j.data.message.SystemMessage;
10 9 import dev.langchain4j.data.message.ToolExecutionResultMessage;
11 10 import dev.langchain4j.data.message.UserMessage;
... ... @@ -17,14 +16,17 @@ import java.util.List;
17 16 import java.util.Map;
18 17  
19 18 /**
20   - * 事件日志上的对话记忆:{@link #add} 把模型环消息逐条 append 成事件(rightPush 原子——
21   - * 对话流与确认端点并发写互不覆盖,替代旧 chat:mem 整包读改写),{@link #messages()} 读取
22   - * {@link EventProjectionService} 的四段式投影。日志即唯一事实源,本类不持有任何会话状态。
  19 + * 事件账本上的对话记忆:{@link #add} 把模型环消息逐条 append 成事件(追加原子——
  20 + * 对话流与按钮端点并发写互不覆盖),{@link #messages()} 读取
  21 + * {@link EventProjectionService} 的四段式 token 记账投影。账本即唯一事实源,本类不持有任何会话状态。
23 22 *
24 23 * <p>写者分工:用户事件由控制器在收到请求时先落账(前端立即可见),本类对 UserMessage 做
25 24 * 去重跳过;{@code internalUserTurn=true} 时(反编造护栏重试的注入话术)例外——落一条
26 25 * {@code internal} 标记的用户事件,LLM 可见、前端历史不显示。模型消息(tool_call/tool_result/ai)
27 26 * 只由本类落账。
  27 + *
  28 + * <p>tool_call 事件存**中立格式** {@code calls=[{id,name,args}]}(脱 LangChain4j 序列化耦合),
  29 + * 旧版 {@code payload} 载荷投影层仍可读。
28 30 */
29 31 public class EventLogChatMemory implements ChatMemory {
30 32  
... ... @@ -33,23 +35,23 @@ public class EventLogChatMemory implements ChatMemory {
33 35 private final String convId;
34 36 private final LedgerService log;
35 37 private final EventProjectionService projection;
36   - private final int charBudget;
  38 + private final AgentIdentity identity;
37 39 private final boolean internalUserTurn;
38 40  
39   - /** system prompt 每次由 provider 提供,不落日志(保持日志纯业务事件)。 */
  41 + /** system prompt 每次由 provider 提供,不落账(保持账本纯业务事件)。 */
40 42 private volatile String systemText;
41 43 /**
42   - * 本轮实际喂给模型的用户文本(原话 + 编排层附加的 grounding/状态后缀)。日志只存原话;
  44 + * 本轮实际喂给模型的用户文本(原话 + 编排层附加的后缀)。账本只存原话;
43 45 * 投影时把当前轮用户消息替换为它。P2 编排层不再加后缀后,此字段恒空。
44 46 */
45 47 private volatile String currentTurnUserText;
46 48  
47 49 public EventLogChatMemory(String convId, LedgerService log, EventProjectionService projection,
48   - int charBudget, boolean internalUserTurn) {
  50 + AgentIdentity identity, boolean internalUserTurn) {
49 51 this.convId = convId;
50 52 this.log = log;
51 53 this.projection = projection;
52   - this.charBudget = charBudget;
  54 + this.identity = identity;
53 55 this.internalUserTurn = internalUserTurn;
54 56 }
55 57  
... ... @@ -75,22 +77,28 @@ public class EventLogChatMemory implements ChatMemory {
75 77 if (internalUserTurn) {
76 78 data.put("internal", true);
77 79 }
78   - log.append(convId, "user", data);
  80 + log.append(convId, "user", data, identity);
79 81 return;
80 82 }
81 83 if (m instanceof AiMessage am) {
82 84 if (am.hasToolExecutionRequests()) {
83 85 Map<String, Object> data = new LinkedHashMap<>();
84   - data.put("payload", ChatMessageSerializer.messagesToJson(List.of(am)));
85 86 data.put("text", am.text() == null ? "" : am.text());
  87 + List<Map<String, Object>> calls = new ArrayList<>();
86 88 List<String> tools = new ArrayList<>();
87 89 for (ToolExecutionRequest r : am.toolExecutionRequests()) {
  90 + Map<String, Object> c = new LinkedHashMap<>();
  91 + c.put("id", r.id() == null ? "" : r.id());
  92 + c.put("name", r.name() == null ? "" : r.name());
  93 + c.put("args", r.arguments() == null ? "" : r.arguments());
  94 + calls.add(c);
88 95 tools.add(r.name());
89 96 }
  97 + data.put("calls", calls);
90 98 data.put("tools", tools);
91   - log.append(convId, "tool_call", data);
  99 + log.append(convId, "tool_call", data, identity);
92 100 } else if (am.text() != null && !am.text().isBlank()) {
93   - log.append(convId, "ai", Map.of("text", am.text()));
  101 + log.append(convId, "ai", Map.of("text", am.text()), identity);
94 102 }
95 103 return;
96 104 }
... ... @@ -101,13 +109,13 @@ public class EventLogChatMemory implements ChatMemory {
101 109 data.put("name", tr.toolName() == null ? "" : tr.toolName());
102 110 data.put("text", text);
103 111 data.put("digest", digest(text));
104   - log.append(convId, "tool_result", data);
  112 + log.append(convId, "tool_result", data, identity);
105 113 }
106 114 }
107 115  
108 116 /**
109 117 * 控制器已把本轮用户**原话**落账(最近的用户事件是喂给模型文本的前缀、且其后无模型事件)
110   - * → 跳过,避免重复。编排层可能在原话后附加 grounding/状态后缀,故用前缀而非全等判定。
  118 + * → 跳过,避免重复。编排层可能在原话后附加后缀,故用前缀而非全等判定。
111 119 */
112 120 private boolean alreadyLoggedPrefixOf(String text) {
113 121 List<Map<String, Object>> tail = log.events(convId, 6);
... ... @@ -120,7 +128,7 @@ public class EventLogChatMemory implements ChatMemory {
120 128 case "form_submit", "ai", "tool_call", "tool_result", "assistant":
121 129 return false;
122 130 default:
123   - // confirm/proposal 等按钮事件可能插在中间,继续往前找
  131 + // queued/confirm 等按钮事件可能插在中间,继续往前找
124 132 }
125 133 }
126 134 return false;
... ... @@ -128,8 +136,7 @@ public class EventLogChatMemory implements ChatMemory {
128 136  
129 137 @Override
130 138 public List<ChatMessage> messages() {
131   - List<ChatMessage> out = projection.project(
132   - systemText, log.events(convId, LLM_EVENT_WINDOW), charBudget);
  139 + List<ChatMessage> out = projection.project(systemText, log.events(convId, LLM_EVENT_WINDOW));
133 140 String full = currentTurnUserText;
134 141 if (full != null) {
135 142 // 当前轮用户消息换成实际喂给模型的完整文本(原话+编排后缀)
... ... @@ -144,7 +151,7 @@ public class EventLogChatMemory implements ChatMemory {
144 151 return out;
145 152 }
146 153  
147   - /** 日志是唯一事实源,不因模型环异常清史;删除会话走 ConversationService 级联。 */
  154 + /** 账本是唯一事实源,不因模型环异常清史;删除会话走 ConversationService 级联。 */
148 155 @Override
149 156 public void clear() {
150 157 }
... ...
src/main/java/com/xly/config/AgentFactory.java
... ... @@ -91,9 +91,9 @@ public class AgentFactory {
91 91 .streamingChatModel(streamingModel)
92 92 .tools(tools)
93 93 .maxSequentialToolsInvocations(8) // 循环护栏:防止 askUser/工具无限自我循环
94   - // 事件日志记忆:逐条 append 原子落账,读取为四段式投影(见 EventLogChatMemory)
  94 + // 事件账本记忆:逐条 append 原子落账,读取为四段式 token 记账投影(见 EventLogChatMemory)
95 95 .chatMemoryProvider(memoryId -> new EventLogChatMemory(
96   - String.valueOf(memoryId), ledger, projection, 6000, internalUserTurn))
  96 + String.valueOf(memoryId), ledger, projection, identity, internalUserTurn))
97 97 .systemMessageProvider(memoryId -> systemPromptService.prompt())
98 98 .build();
99 99 }
... ...
src/main/java/com/xly/config/TracingChatModelListener.java
... ... @@ -53,12 +53,21 @@ public class TracingChatModelListener implements ChatModelListener {
53 53 this.mapper = mapper;
54 54 }
55 55  
  56 + @org.springframework.beans.factory.annotation.Value("${llm.context-length:16384}")
  57 + private int contextLength;
  58 +
56 59 @Override
57 60 public void onRequest(ChatModelRequestContext ctx) {
58 61 ctx.attributes().put("t0", System.nanoTime());
59 62 ctx.attributes().put("startTs", Instant.now().toString());
60 63 String model = ctx.chatRequest() == null ? null : ctx.chatRequest().modelName();
61 64 ctx.attributes().put("model", model == null ? "unknown" : model);
  65 + try { // 校准回路:记下本地保守估算,onResponse 时与 prompt_eval_count 对账
  66 + if (ctx.chatRequest() != null && ctx.chatRequest().messages() != null) {
  67 + ctx.attributes().put("estIn", com.xly.service.TokenEstimator.estimate(ctx.chatRequest().messages()));
  68 + }
  69 + } catch (Exception ignore) {
  70 + }
62 71 }
63 72  
64 73 @Override
... ... @@ -73,10 +82,30 @@ public class TracingChatModelListener implements ChatModelListener {
73 82 }
74 83 } catch (Exception ignore) {
75 84 }
  85 + calibrate(ctx.attributes().get("estIn"), in, out);
76 86 record(String.valueOf(ctx.attributes().get("model")), ctx.attributes().get("t0"),
77 87 String.valueOf(ctx.attributes().get("startTs")), in, out, null);
78 88 }
79 89  
  90 + /**
  91 + * token 估算校准回路:prompt_eval_count(响应免费自带)vs 本地保守估算。
  92 + * 实际 &gt; 估算 = 估算器不够保守(截头风险);实际+输出逼近 num_ctx = 很可能已被 Ollama 静默截头,都告警。
  93 + */
  94 + private void calibrate(Object est, Integer actualIn, Integer actualOut) {
  95 + if (!(est instanceof Integer e) || actualIn == null || actualIn <= 0) {
  96 + return;
  97 + }
  98 + double ratio = actualIn / (double) e;
  99 + log.info("LLM token calib est={} actual={} ratio={}", e, actualIn, String.format("%.2f", ratio));
  100 + if (actualIn > e) {
  101 + log.warn("LLM token 估算偏低(est={} < actual={})——保守系数不足,存在被 num_ctx 截头的风险", e, actualIn);
  102 + }
  103 + int total = actualIn + (actualOut == null ? 0 : actualOut);
  104 + if (total > contextLength * 0.95) {
  105 + log.warn("LLM 上下文逼近 num_ctx={}(in+out={}),Ollama 可能已从最前静默截断——请检查投影预算", contextLength, total);
  106 + }
  107 + }
  108 +
80 109 @Override
81 110 public void onError(ChatModelErrorContext ctx) {
82 111 Throwable e = ctx.error();
... ...
src/main/java/com/xly/service/AuthzService.java
... ... @@ -64,7 +64,7 @@ public class AuthzService {
64 64 String subsidiaryId = w.path("sSubsidiaryId").asText("");
65 65 String userType = w.path("sType").asText("");
66 66 Set<String> granted = grantedIds(userId, userType, brandsId, subsidiaryId);
67   - return new com.xly.agent.AgentIdentity(token, userId, brandsId, granted);
  67 + return new com.xly.agent.AgentIdentity(token, userId, brandsId, subsidiaryId, granted);
68 68 }
69 69 return erp.devLoginEnabled() ? devIdentity() : null;
70 70 }
... ... @@ -76,7 +76,7 @@ public class AuthzService {
76 76 public com.xly.agent.AgentIdentity devIdentity() {
77 77 String uid = devUserIdOverride != null && !devUserIdOverride.isBlank() ? devUserIdOverride : resolveDevUserId();
78 78 Set<String> granted = grantedIds(uid, devUserType, devBrand, devSub);
79   - return new com.xly.agent.AgentIdentity(null, uid, devBrand, granted);
  79 + return new com.xly.agent.AgentIdentity(null, uid, devBrand, devSub, granted);
80 80 }
81 81  
82 82 /** null = 全部(管理员);否则 = 有权的 id 集合。 */
... ...
src/main/java/com/xly/service/EventProjectionService.java
... ... @@ -2,123 +2,302 @@ package com.xly.service;
2 2  
3 3 import com.fasterxml.jackson.databind.JsonNode;
4 4 import com.fasterxml.jackson.databind.ObjectMapper;
  5 +import dev.langchain4j.agent.tool.ToolExecutionRequest;
5 6 import dev.langchain4j.data.message.AiMessage;
6 7 import dev.langchain4j.data.message.ChatMessage;
7 8 import dev.langchain4j.data.message.ChatMessageDeserializer;
8 9 import dev.langchain4j.data.message.SystemMessage;
9 10 import dev.langchain4j.data.message.ToolExecutionResultMessage;
10 11 import dev.langchain4j.data.message.UserMessage;
  12 +import org.slf4j.Logger;
  13 +import org.slf4j.LoggerFactory;
  14 +import org.springframework.beans.factory.annotation.Value;
11 15 import org.springframework.stereotype.Service;
12 16  
13 17 import java.util.ArrayList;
14 18 import java.util.LinkedHashMap;
15 19 import java.util.List;
16 20 import java.util.Map;
  21 +import java.util.function.UnaryOperator;
17 22  
18 23 /**
19   - * 事件日志的**唯一**投影/渲染器 —— 前端历史与 LLM 上下文同源于 {@link LedgerService} 的事件流,
  24 + * 事件账本的**唯一**投影/渲染器 —— 前端历史与 LLM 上下文同源于 {@link LedgerService} 的事件流,
20 25 * 文案在这一处生成,不再散落在各控制器里。
21 26 *
22   - * <p><b>LLM 上下文四段式</b>({@link #project}):
  27 + * <p><b>LLM 上下文四段式</b>({@link #project}),预算按 <b>token</b> 记账(本地保守估算
  28 + * {@link TokenEstimator},绝对值由 {@code llm.context-length}(镜像 Ollama 的
  29 + * OLLAMA_CONTEXT_LENGTH,经 OpenAI 兼容通道无法逐请求传 num_ctx)导出):
23 30 * <ol>
24   - * <li>稳定 system prompt(KV 前缀稳定);</li>
25   - * <li>进行中流程卡(激活 skill 全文 + 在办单据状态)——钉在 system 尾部,不参与截断,
26   - * 提议 executed/cancelled 或新 skill 激活时摘下;</li>
27   - * <li>往事摘要区:预算外旧轮 → 确定性一行摘要(零模型调用);</li>
28   - * <li>近期原文区:约 charBudget 字符按**整轮**纳入;旧轮工具结果压 {@value #TOOL_DIGEST_LEN} 字;
29   - * 当前轮(最后一个用户事件起)原样载荷精确重建,工具调用/结果配对不可破坏。</li>
  31 + * <li>① 稳定 system prompt + ② 进行中流程卡 —— 永不让位;</li>
  32 + * <li>④ 往事摘要区:预算外旧轮 → 确定性一行摘要(零模型调用);</li>
  33 + * <li>⑤ 近期原文区:按**整轮**纳入,旧轮工具结果压 {@value #TOOL_DIGEST_LEN} 字;</li>
  34 + * <li>当前轮:原样保真、工具配对不可破坏;当前轮自身超限时**轮内让位**——最早的工具结果
  35 + * 降为摘要(保最近 {@value #CURRENT_KEEP_FULL} 个全文),单条结果超硬顶截断。</li>
30 36 * </ol>
  37 + * 组装后做**发送前自检**:总 token 超预算 → ⑤ 最旧轮退入 ④、再裁 ④ 最旧行;①②与当前轮不动。
  38 + * 绝不把超限上下文交给 Ollama 静默截头(它从最前截、① 先死)。
31 39 */
32 40 @Service
33 41 public class EventProjectionService {
34 42  
  43 + private static final Logger log = LoggerFactory.getLogger(EventProjectionService.class);
  44 +
35 45 public static final int TOOL_DIGEST_LEN = 120;
36 46 private static final int DIGEST_TURNS_MAX = 40;
37 47 private static final int DIGEST_LINE_LEN = 90;
38 48  
  49 + /** 模型输出预留(num_ctx 覆盖 prompt+生成)。 */
  50 + private static final int OUTPUT_RESERVE = 1024;
  51 + /** 工具 schema(随每次请求发送、不在 messages 里)的估算开销。 */
  52 + private static final int TOOLS_OVERHEAD = 2000;
  53 + /** 当前轮增长的乐观小额预留(每次工具往返都会重投影,超出时反应式收缩⑤)。 */
  54 + private static final int TURN_RESERVE = 512;
  55 + /** ④ 摘要区 token 上限。 */
  56 + private static final int DIGEST_BUDGET = 800;
  57 + /** 当前轮里保全文的最近工具结果个数(轮内让位时更早的降摘要)。 */
  58 + private static final int CURRENT_KEEP_FULL = 2;
  59 + /** 当前轮单条工具结果硬顶(token),超出截断。 */
  60 + private static final int SINGLE_RESULT_CAP = 2400;
  61 +
39 62 private final ObjectMapper mapper;
  63 + private final int contextLength;
40 64  
41   - public EventProjectionService(ObjectMapper mapper) {
  65 + /** opId → 人类可读状态行(Phase C:流程卡只读展示 ai_op_queue.sStatus)。可空(测试/未装配)。 */
  66 + private volatile UnaryOperator<String> opStatusLookup;
  67 +
  68 + public EventProjectionService(ObjectMapper mapper, @Value("${llm.context-length:16384}") int contextLength) {
42 69 this.mapper = mapper;
  70 + this.contextLength = contextLength;
  71 + }
  72 +
  73 + public EventProjectionService(ObjectMapper mapper) {
  74 + this(mapper, 16384);
  75 + }
  76 +
  77 + public void setOpStatusLookup(UnaryOperator<String> lookup) {
  78 + this.opStatusLookup = lookup;
  79 + }
  80 +
  81 + /** 输入 prompt 的总预算(token)。 */
  82 + int promptBudget() {
  83 + return Math.max(2048, contextLength - OUTPUT_RESERVE - TOOLS_OVERHEAD);
43 84 }
44 85  
45 86 // ---------------------------------------------------------------- LLM 投影
46 87  
47   - /** 四段式 LLM 上下文。systemText 为空时不出 system 消息(如测试)。 */
48   - public List<ChatMessage> project(String systemText, List<Map<String, Object>> events, int charBudget) {
  88 + /** 四段式 LLM 上下文(token 记账)。systemText 为空时不出 system 消息(如测试)。 */
  89 + public List<ChatMessage> project(String systemText, List<Map<String, Object>> events) {
  90 + int budget = promptBudget();
49 91 List<List<Map<String, Object>>> turns = groupTurns(events);
50 92 int current = turns.size() - 1;
51 93  
52   - // ④ 近期原文区:从最新往回按整轮纳入;当前轮必进且不计预算
53   - int firstVerbatim = current;
  94 + // ①② system + 流程卡(永不让位)
  95 + SystemMessage sys = null;
  96 + int sysEst = 0;
  97 + if (systemText != null && !systemText.isBlank()) {
  98 + String card = activeCard(events);
  99 + sys = SystemMessage.from(card == null ? systemText
  100 + : systemText + "\n\n【进行中的流程】\n" + card);
  101 + sysEst = TokenEstimator.estimate(sys);
  102 + }
  103 +
  104 + // 当前轮:原样保真;自身超限时轮内让位
  105 + List<ChatMessage> currentMsgs = new ArrayList<>();
  106 + int curEst = 0;
  107 + if (current >= 0) {
  108 + int curAllowance = budget - sysEst - TURN_RESERVE;
  109 + currentMsgs = renderCurrentTurn(turns.get(current), curAllowance);
  110 + curEst = TokenEstimator.estimate(currentMsgs);
  111 + }
  112 +
  113 + // ⑤ 近期原文区:从最新旧轮往回按整轮纳入
  114 + List<List<ChatMessage>> oldRendered = new ArrayList<>(); // index 对齐 turns[0..current-1]
  115 + int[] oldEst = new int[Math.max(0, current)];
  116 + for (int i = 0; i < current; i++) {
  117 + List<ChatMessage> ms = new ArrayList<>();
  118 + appendTurnMessages(ms, turns.get(i), false);
  119 + oldRendered.add(ms);
  120 + oldEst[i] = TokenEstimator.estimate(ms);
  121 + }
  122 + int remaining = budget - sysEst - curEst - TURN_RESERVE;
  123 + int firstVerbatim = fillRecent(oldEst, current, remaining, 0);
  124 + if (firstVerbatim > 0) { // 有轮进摘要区 → 给 ④ 留预算后重算
  125 + firstVerbatim = fillRecent(oldEst, current, remaining, DIGEST_BUDGET);
  126 + }
  127 +
  128 + // ④ 摘要行
  129 + List<String> digestLines = new ArrayList<>();
  130 + int digestFrom = Math.max(0, firstVerbatim - DIGEST_TURNS_MAX);
  131 + for (int i = digestFrom; i < firstVerbatim; i++) {
  132 + String line = digestLine(turns.get(i));
  133 + if (!line.isBlank()) {
  134 + digestLines.add(line);
  135 + }
  136 + }
  137 + int omitted = digestFrom;
  138 +
  139 + // 组装 + 发送前自检(⑤ 最旧退 ④ → 裁 ④ 最旧;①② 与当前轮不动)
  140 + while (true) {
  141 + List<ChatMessage> out = assemble(sys, digestLines, omitted, oldRendered, firstVerbatim, current, currentMsgs);
  142 + int total = TokenEstimator.estimate(out);
  143 + if (total <= budget) {
  144 + return out;
  145 + }
  146 + if (firstVerbatim < current) {
  147 + String line = digestLine(turns.get(firstVerbatim));
  148 + if (!line.isBlank()) {
  149 + digestLines.add(line);
  150 + }
  151 + firstVerbatim++;
  152 + continue;
  153 + }
  154 + if (!digestLines.isEmpty()) {
  155 + digestLines.remove(0);
  156 + omitted++;
  157 + continue;
  158 + }
  159 + log.warn("projection over budget even after shrink: total≈{}t budget={}t (当前轮过大)", total, budget);
  160 + return out;
  161 + }
  162 + }
  163 +
  164 + /** ⑤ 从最新旧轮往回装,返回 firstVerbatim(第一条进原文区的旧轮下标;current 表示一条都装不下)。 */
  165 + private static int fillRecent(int[] oldEst, int current, int remaining, int digestAllowance) {
  166 + int budget = remaining - digestAllowance;
54 167 int used = 0;
  168 + int first = current;
55 169 for (int i = current - 1; i >= 0; i--) {
56   - int size = 0;
57   - for (Map<String, Object> ev : turns.get(i)) {
58   - size += approxLen(ev);
59   - }
60   - if (used + size > charBudget) {
  170 + if (used + oldEst[i] > budget) {
61 171 break;
62 172 }
63   - used += size;
64   - firstVerbatim = i;
  173 + used += oldEst[i];
  174 + first = i;
65 175 }
  176 + return first;
  177 + }
66 178  
  179 + private List<ChatMessage> assemble(SystemMessage sys, List<String> digestLines, int omitted,
  180 + List<List<ChatMessage>> oldRendered, int firstVerbatim, int current,
  181 + List<ChatMessage> currentMsgs) {
67 182 List<ChatMessage> out = new ArrayList<>();
68   - String card = activeCard(events);
69   - if (systemText != null && !systemText.isBlank()) {
70   - out.add(SystemMessage.from(card == null ? systemText
71   - : systemText + "\n\n【进行中的流程】\n" + card));
  183 + if (sys != null) {
  184 + out.add(sys);
72 185 }
73   -
74   - // ③ 往事摘要区
75   - if (firstVerbatim > 0) {
  186 + if (!digestLines.isEmpty() || omitted > 0) {
76 187 StringBuilder sb = new StringBuilder("【更早对话摘要(自动截断,仅供参考)】");
77   - int from = Math.max(0, firstVerbatim - DIGEST_TURNS_MAX);
78   - if (from > 0) {
79   - sb.append("\n(更早 ").append(from).append(" 轮已省略)");
  188 + if (omitted > 0) {
  189 + sb.append("\n(更早 ").append(omitted).append(" 轮已省略)");
80 190 }
81   - for (int i = from; i < firstVerbatim; i++) {
82   - String line = digestLine(turns.get(i));
83   - if (!line.isBlank()) {
84   - sb.append("\n- ").append(line);
85   - }
  191 + for (String line : digestLines) {
  192 + sb.append("\n- ").append(line);
86 193 }
87 194 out.add(UserMessage.from(sb.toString()));
88 195 }
  196 + for (int i = firstVerbatim; i < current; i++) {
  197 + out.addAll(oldRendered.get(i));
  198 + }
  199 + out.addAll(currentMsgs);
  200 + return out;
  201 + }
89 202  
90   - for (int i = firstVerbatim; i < turns.size(); i++) {
91   - appendTurnMessages(out, turns.get(i), i == current);
  203 + /**
  204 + * 当前轮渲染:原样保真;总量超 allowance 时**轮内让位**——最早的工具结果先降
  205 + * {@value #TOOL_DIGEST_LEN} 字摘要(保最近 {@value #CURRENT_KEEP_FULL} 个全文,仍超则只保 1 个)。
  206 + * 工具调用/结果配对永不破坏,单条结果超 {@value #SINGLE_RESULT_CAP}t 截断。
  207 + */
  208 + private List<ChatMessage> renderCurrentTurn(List<Map<String, Object>> turn, int allowance) {
  209 + for (int keepFull = CURRENT_KEEP_FULL; keepFull >= 0; keepFull--) {
  210 + List<ChatMessage> ms = renderCurrentWith(turn, keepFull);
  211 + if (TokenEstimator.estimate(ms) <= allowance || keepFull == 0) {
  212 + return ms;
  213 + }
  214 + }
  215 + return List.of();
  216 + }
  217 +
  218 + private List<ChatMessage> renderCurrentWith(List<Map<String, Object>> turn, int keepFull) {
  219 + int results = 0;
  220 + for (Map<String, Object> ev : turn) {
  221 + if ("tool_result".equals(str(ev.get("type")))) {
  222 + results++;
  223 + }
  224 + }
  225 + int demote = Math.max(0, results - keepFull);
  226 + List<ChatMessage> out = new ArrayList<>();
  227 + int seen = 0;
  228 + for (Map<String, Object> ev : turn) {
  229 + if ("tool_result".equals(str(ev.get("type")))) {
  230 + seen++;
  231 + String text = seen <= demote ? str(ev.get("digest")) : capped(str(ev.get("text")));
  232 + out.add(ToolExecutionResultMessage.from(str(ev.get("tcId")), str(ev.get("name")), text));
  233 + } else {
  234 + appendOneMessage(out, ev, true);
  235 + }
92 236 }
93 237 return out;
94 238 }
95 239  
  240 + private static String capped(String text) {
  241 + if (TokenEstimator.estimate(text) <= SINGLE_RESULT_CAP) {
  242 + return text;
  243 + }
  244 + // 按估算规则回推一个安全字符数(全 CJK 最坏情形 = 1 字 1 token)
  245 + return text.substring(0, Math.min(text.length(), SINGLE_RESULT_CAP)) + "……[超长已截断]";
  246 + }
  247 +
96 248 /** 一轮 → LLM 消息。当前轮工具结果全文保真;旧轮压一行摘要。 */
97 249 private void appendTurnMessages(List<ChatMessage> out, List<Map<String, Object>> turn, boolean isCurrent) {
98 250 for (Map<String, Object> ev : turn) {
99   - String type = str(ev.get("type"));
100   - switch (type) {
101   - case "user" -> out.add(UserMessage.from(str(ev.get("text"))));
102   - case "form_submit" -> out.add(UserMessage.from(renderFormSubmit(ev)));
103   - case "ai", "assistant", "clarify" -> addAi(out, str(ev.get("text")));
104   - case "tool_call" -> {
105   - ChatMessage m = fromPayload(str(ev.get("payload")));
106   - if (m != null) {
107   - out.add(m);
108   - }
  251 + if ("tool_result".equals(str(ev.get("type")))) {
  252 + String text = isCurrent ? str(ev.get("text")) : str(ev.get("digest"));
  253 + out.add(ToolExecutionResultMessage.from(str(ev.get("tcId")), str(ev.get("name")), text));
  254 + } else {
  255 + appendOneMessage(out, ev, isCurrent);
  256 + }
  257 + }
  258 + }
  259 +
  260 + private void appendOneMessage(List<ChatMessage> out, Map<String, Object> ev, boolean isCurrent) {
  261 + String type = str(ev.get("type"));
  262 + switch (type) {
  263 + case "user" -> out.add(UserMessage.from(str(ev.get("text"))));
  264 + case "form_submit" -> out.add(UserMessage.from(renderFormSubmit(ev)));
  265 + case "ai", "assistant", "clarify" -> addAi(out, str(ev.get("text")));
  266 + case "tool_call" -> {
  267 + ChatMessage m = toolCallMessage(ev);
  268 + if (m != null) {
  269 + out.add(m);
109 270 }
110   - case "tool_result" -> {
111   - String text = isCurrent ? str(ev.get("text")) : str(ev.get("digest"));
112   - out.add(ToolExecutionResultMessage.from(
113   - str(ev.get("tcId")), str(ev.get("name")), text));
  271 + }
  272 + case "question" -> addAi(out, str(ev.get("question")));
  273 + case "form" -> addAi(out, "已为「" + str(ev.get("entity")) + "」弹出新建表单,等待用户填写提交。");
  274 + case "queued" -> addAi(out, renderQueued(ev));
  275 + case "proposal" -> addAi(out, "已生成待确认提议:" + str(ev.get("summary")) + "(等待用户点确认/取消,尚未执行)");
  276 + case "confirm", "cancel" -> addAi(out, renderOutcome(ev));
  277 + default -> { } // tool(旧版摘要)/skill_active/skill_done 不进正文(skill 由流程卡承载)
  278 + }
  279 + }
  280 +
  281 + /** tool_call 事件 → AiMessage:优先中立格式 calls=[{id,name,args}],回退旧版 LangChain4j 序列化载荷。 */
  282 + private ChatMessage toolCallMessage(Map<String, Object> ev) {
  283 + Object calls = ev.get("calls");
  284 + if (calls instanceof List<?> list && !list.isEmpty()) {
  285 + List<ToolExecutionRequest> reqs = new ArrayList<>();
  286 + for (Object o : list) {
  287 + if (o instanceof Map<?, ?> m) {
  288 + reqs.add(ToolExecutionRequest.builder()
  289 + .id(str(m.get("id")))
  290 + .name(str(m.get("name")))
  291 + .arguments(str(m.get("args")))
  292 + .build());
114 293 }
115   - case "question" -> addAi(out, str(ev.get("question")));
116   - case "form" -> addAi(out, "已为「" + str(ev.get("entity")) + "」弹出新建表单,等待用户填写提交。");
117   - case "proposal" -> addAi(out, "已生成待确认提议:" + str(ev.get("summary")) + "(等待用户点确认/取消,尚未执行)");
118   - case "confirm", "cancel" -> addAi(out, renderOutcome(ev));
119   - default -> { } // tool(旧版摘要)/skill_active/skill_done 不进正文(skill 由流程卡承载)
  294 + }
  295 + if (!reqs.isEmpty()) {
  296 + String text = str(ev.get("text"));
  297 + return text.isBlank() ? AiMessage.from(reqs) : AiMessage.from(text, reqs);
120 298 }
121 299 }
  300 + return fromPayload(str(ev.get("payload")));
122 301 }
123 302  
124 303 // ---------------------------------------------------------------- 前端历史投影
... ... @@ -138,6 +317,7 @@ public class EventProjectionService {
138 317 case "ai", "assistant", "clarify" -> addHist(out, str(ev.get("text")));
139 318 case "question" -> addHist(out, str(ev.get("question")));
140 319 case "form" -> addHist(out, "【表单】新建" + str(ev.get("entity")) + ":" + str(ev.get("message")));
  320 + case "queued" -> addHist(out, "【已提交待办】" + str(ev.get("description")));
141 321 case "proposal" -> addHist(out, "【待确认】" + str(ev.get("summary")));
142 322 case "confirm" -> addHist(out, ("executed".equals(str(ev.get("status"))) ? "【已执行】" : "【执行失败】")
143 323 + str(ev.get("description")) + parenOr(str(ev.get("msg"))));
... ... @@ -149,10 +329,11 @@ public class EventProjectionService {
149 329 return out;
150 330 }
151 331  
152   - /** agent 路径的 提问/表单/提议 不再单独落显示事件——从工具结果同源推导。 */
  332 + /** agent 路径的 提问/表单/预览 不再单独落显示事件——从工具结果同源推导。 */
153 333 private String historyFromToolResult(Map<String, Object> ev) {
154 334 String name = str(ev.get("name"));
155   - if (!"askUser".equals(name) && !"collectForm".equals(name) && !"proposeWrite".equals(name)) {
  335 + if (!"askUser".equals(name) && !"collectForm".equals(name)
  336 + && !"previewChange".equals(name) && !"proposeWrite".equals(name)) {
156 337 return "";
157 338 }
158 339 try {
... ... @@ -163,6 +344,9 @@ public class EventProjectionService {
163 344 if ("collectForm".equals(name) && "form_collect".equals(r.path("type").asText(""))) {
164 345 return "【表单】新建" + r.path("entity").asText("") + ":" + r.path("message").asText("");
165 346 }
  347 + if ("previewChange".equals(name) && "change_preview".equals(r.path("type").asText(""))) {
  348 + return "【预览】" + r.path("summary").asText("");
  349 + }
166 350 if ("proposeWrite".equals(name) && !r.path("opId").asText("").isBlank()) {
167 351 return "【待确认】" + r.path("summary").asText("");
168 352 }
... ... @@ -175,14 +359,13 @@ public class EventProjectionService {
175 359  
176 360 /**
177 361 * 进行中流程卡:激活 skill 全文(最近一次 {@code skill_active},被 {@code skill_done} 或更新的
178   - * skill 覆盖前一直钉住)+ 在办单据状态(表单待提交 / 提议待确认;executed/cancelled/failed 即摘下)。
  362 + * skill 覆盖前一直钉住)+ 在办单据状态(表单待提交 / 预览待保存 / 已提交待办的只读进度)。
  363 + * {@code queued} 后技能线摘下(AI 侧流程到写入队列为止),待办行保留到下一个流程事件替换。
179 364 * 两者皆无 → null。
180 365 */
181 366 public String activeCard(List<Map<String, Object>> events) {
182 367 String skillText = null;
183   - Map<String, Object> lastFlow = null; // form/collectForm/proposal/confirm/cancel/form_submit 中最近的一个
184   - String proposalOpId = null;
185   - String proposalSummary = null;
  368 + Map<String, Object> lastFlow = null;
186 369  
187 370 for (Map<String, Object> ev : events) {
188 371 String type = str(ev.get("type"));
... ... @@ -190,34 +373,30 @@ public class EventProjectionService {
190 373 case "skill_active" -> skillText = str(ev.get("text"));
191 374 case "skill_done" -> skillText = null;
192 375 case "form", "form_submit", "proposal" -> lastFlow = ev;
193   - case "confirm", "cancel" -> { // 流程终结:单据线与技能线一起摘下
  376 + case "queued", "confirm", "cancel" -> { // AI 侧流程终结:技能线摘下
194 377 lastFlow = ev;
195 378 skillText = null;
196 379 }
197 380 case "tool_result" -> {
198   - String name = str(ev.get("name"));
199   - if ("collectForm".equals(name) || "proposeWrite".equals(name)) {
200   - Map<String, Object> derived = deriveFlowEvent(ev);
201   - if (derived != null) {
202   - lastFlow = derived;
203   - }
  381 + Map<String, Object> derived = deriveFlowEvent(ev);
  382 + if (derived != null) {
  383 + lastFlow = derived;
204 384 }
205 385 }
206 386 default -> { }
207 387 }
208   - if (lastFlow != null && "proposal".equals(str(lastFlow.get("type")))) {
209   - proposalOpId = str(lastFlow.get("opId"));
210   - proposalSummary = str(lastFlow.get("summary"));
211   - }
212 388 }
213 389  
214 390 String docLine = null;
215 391 if (lastFlow != null) {
216 392 switch (str(lastFlow.get("type"))) {
217 393 case "form" -> docLine = "已为「" + str(lastFlow.get("entity")) + "」弹出新建表单,等待用户填写提交。";
218   - case "proposal" -> docLine = "待确认提议:" + proposalSummary
219   - + "(opId=" + proposalOpId + ",用户点【确认】才会执行)。";
220   - default -> { } // form_submit/confirm/cancel = 流程已推进/终结,卡摘下
  394 + case "preview" -> docLine = "已生成写操作预览卡:" + str(lastFlow.get("summary"))
  395 + + "(等待用户点卡上按钮提交,**尚未写入**)。";
  396 + case "queued" -> docLine = renderQueuedLine(lastFlow);
  397 + case "proposal" -> docLine = "待确认提议:" + str(lastFlow.get("summary"))
  398 + + "(opId=" + str(lastFlow.get("opId")) + ",用户点【确认】才会执行)。";
  399 + default -> { } // form_submit/confirm/cancel = 流程已推进/终结
221 400 }
222 401 }
223 402  
... ... @@ -237,17 +416,28 @@ public class EventProjectionService {
237 416 return sb.toString();
238 417 }
239 418  
240   - /** 工具结果 → 流程事件(collectForm 弹表单 / proposeWrite 出提议),供流程卡推导。 */
  419 + /** 工具结果 → 流程事件(collectForm 弹表单 / previewChange 出预览 / 旧版 proposeWrite),供流程卡推导。 */
241 420 private Map<String, Object> deriveFlowEvent(Map<String, Object> ev) {
  421 + String name = str(ev.get("name"));
  422 + if (!"collectForm".equals(name) && !"previewChange".equals(name) && !"proposeWrite".equals(name)) {
  423 + return null;
  424 + }
242 425 try {
243 426 JsonNode r = mapper.readTree(str(ev.get("text")));
244   - if ("collectForm".equals(str(ev.get("name"))) && "form_collect".equals(r.path("type").asText(""))) {
  427 + if ("collectForm".equals(name) && "form_collect".equals(r.path("type").asText(""))) {
245 428 Map<String, Object> m = new LinkedHashMap<>();
246 429 m.put("type", "form");
247 430 m.put("entity", r.path("entity").asText(""));
248 431 return m;
249 432 }
250   - if ("proposeWrite".equals(str(ev.get("name"))) && !r.path("opId").asText("").isBlank()) {
  433 + if ("previewChange".equals(name) && "change_preview".equals(r.path("type").asText(""))) {
  434 + Map<String, Object> m = new LinkedHashMap<>();
  435 + m.put("type", "preview");
  436 + m.put("previewId", r.path("previewId").asText(""));
  437 + m.put("summary", r.path("summary").asText(""));
  438 + return m;
  439 + }
  440 + if ("proposeWrite".equals(name) && !r.path("opId").asText("").isBlank()) {
251 441 Map<String, Object> m = new LinkedHashMap<>();
252 442 m.put("type", "proposal");
253 443 m.put("opId", r.path("opId").asText(""));
... ... @@ -291,6 +481,7 @@ public class EventProjectionService {
291 481 case "ai", "assistant", "clarify" -> outcome = str(ev.get("text"));
292 482 case "question" -> outcome = "问:" + str(ev.get("question"));
293 483 case "form" -> outcome = "弹出「" + str(ev.get("entity")) + "」新建表单";
  484 + case "queued" -> outcome = "已提交待办:" + str(ev.get("description"));
294 485 case "proposal" -> outcome = "生成待确认提议:" + str(ev.get("summary"));
295 486 case "confirm" -> outcome = ("executed".equals(str(ev.get("status"))) ? "用户确认,执行成功:" : "确认后执行失败:")
296 487 + str(ev.get("description"));
... ... @@ -323,6 +514,27 @@ public class EventProjectionService {
323 514 return "提交「" + str(ev.get("entity")) + "」新增表单:" + str(ev.get("fields"));
324 515 }
325 516  
  517 + private String renderQueued(Map<String, Object> ev) {
  518 + return "(系统)用户已保存,操作已提交待办:" + str(ev.get("description"))
  519 + + "(AI 侧流程到此为止,是否/何时执行由 ERP 处理;绝不声称已执行成功)";
  520 + }
  521 +
  522 + /** queued 流程卡行:附 ai_op_queue.sStatus 只读进度(可用时)。 */
  523 + private String renderQueuedLine(Map<String, Object> ev) {
  524 + String desc = str(ev.get("description"));
  525 + String status = null;
  526 + UnaryOperator<String> lookup = opStatusLookup;
  527 + Object opIds = ev.get("opIds");
  528 + if (lookup != null && opIds instanceof List<?> ids && !ids.isEmpty()) {
  529 + try {
  530 + status = lookup.apply(String.valueOf(ids.get(0)));
  531 + } catch (Exception ignore) {
  532 + }
  533 + }
  534 + return "已提交待办:" + desc + "(当前状态:" + (status == null || status.isBlank() ? "等待 ERP 处理" : status)
  535 + + ";只读进度,勿再重复提交)。";
  536 + }
  537 +
326 538 private String renderOutcome(Map<String, Object> ev) {
327 539 String desc = str(ev.get("description"));
328 540 if ("cancel".equals(str(ev.get("type")))) {
... ... @@ -335,23 +547,6 @@ public class EventProjectionService {
335 547 return "(系统)用户确认后执行失败:" + desc + (msg.isBlank() ? "" : ",原因:" + msg);
336 548 }
337 549  
338   - private static int approxLen(Map<String, Object> ev) {
339   - String type = str(ev.get("type"));
340   - if ("tool_result".equals(type)) {
341   - return str(ev.get("digest")).length() + 20; // 旧轮只进摘要
342   - }
343   - if ("tool_call".equals(type)) {
344   - return str(ev.get("payload")).length() / 2 + 20;
345   - }
346   - int n = 0;
347   - for (Object v : ev.values()) {
348   - if (v instanceof String s) {
349   - n += s.length();
350   - }
351   - }
352   - return Math.max(n, 20);
353   - }
354   -
355 550 private static void addAi(List<ChatMessage> out, String text) {
356 551 if (text != null && !text.isBlank()) {
357 552 out.add(AiMessage.from(text));
... ...
src/main/java/com/xly/service/LedgerService.java
1 1 package com.xly.service;
2 2  
3 3 import com.fasterxml.jackson.databind.ObjectMapper;
  4 +import com.xly.agent.AgentIdentity;
4 5 import org.slf4j.Logger;
5 6 import org.slf4j.LoggerFactory;
6 7 import org.springframework.data.redis.core.StringRedisTemplate;
  8 +import org.springframework.jdbc.core.JdbcTemplate;
7 9 import org.springframework.stereotype.Service;
8 10  
9 11 import java.time.Duration;
10 12 import java.util.ArrayList;
  13 +import java.util.Collections;
11 14 import java.util.LinkedHashMap;
12 15 import java.util.List;
13 16 import java.util.Map;
14 17  
15 18 /**
16   - * 会话事件日志(append-only)—— 会话的**唯一事实源**:用户话、模型消息(含工具调用/结果的原样载荷)、
17   - * 表单/提议/确认等按钮事件,全部按发生顺序落一条 Redis LIST。前端历史与 LLM 上下文都是它的投影
18   - * ({@link EventProjectionService}),不再有 chat:mem 整包读改写与多处手工同步。
  19 + * 会话事件账本(append-only)—— 会话的**唯一事实源**:用户话、模型消息(含工具调用/结果)、
  20 + * 表单/预览/保存等按钮事件,全部按发生顺序落账。前端历史与 LLM 上下文都是它的投影
  21 + * ({@link EventProjectionService})。
19 22 *
20   - * <p>键:{@code chat:ledger:{convId}},元素为事件 JSON {@code {t,type,...}},30 天 TTL;
21   - * rightPush 原子,任意并发写者(对话流、确认端点)互不覆盖。
  23 + * <p><b>存储分层</b>:MySQL {@code ai_chat_event} 为权威持久层(每事件一行、永不删除、可溯源,
  24 + * 服务端赋 iSeq 会话内序号 + iTurn 轮次),Redis LIST {@code chat:ledger:{convId}}(30 天 TTL)
  25 + * 为热缓存加速投影读;缓存失效时回源 MySQL 并回填。两边任一失败都不阻断对话(互为兜底)。
22 26 *
23 27 * <p>事件类型:
24 28 * {@code user}(text, internal?)/ {@code ai}(text)/
25   - * {@code tool_call}(payload=原样序列化的模型消息, text, tools)/
  29 + * {@code tool_call}(text, calls=[{id,name,args}], tools)/
26 30 * {@code tool_result}(tcId, name, text, digest)/
27   - * {@code form_submit}(entity, fields)/ {@code proposal}(opId, summary)/
28   - * {@code confirm}(opId, status, msg, description)/ {@code cancel}(opId, description)/
  31 + * {@code form_submit}(entity, fields)/ {@code queued}(opIds, description, summaryLines?)/
29 32 * {@code skill_active}(name, text)/ {@code skill_done}(name);
30   - * 旧版遗留类型 {@code assistant/clarify/form/question/tool} 仍可读(兼容渲染,自然淘汰)。
  33 + * 旧版遗留类型 {@code assistant/clarify/form/question/tool/proposal/confirm/cancel} 仍可读(兼容渲染,自然淘汰)。
31 34 */
32 35 @Service
33 36 public class LedgerService {
... ... @@ -35,20 +38,32 @@ public class LedgerService {
35 38 private static final Logger log = LoggerFactory.getLogger(LedgerService.class);
36 39 private static final String PREFIX = "chat:ledger:";
37 40 private static final Duration TTL = Duration.ofDays(30);
  41 + /** MySQL 冷读回源的窗口上限(有界读:超长会话不整包搬运)。 */
  42 + private static final int COLD_READ_MAX = 1000;
  43 + /** 开新轮的事件类型(其余事件继承当前轮次)。 */
  44 + private static final java.util.Set<String> TURN_OPENERS = java.util.Set.of("user", "form_submit");
38 45  
39 46 private final StringRedisTemplate redis;
40 47 private final ObjectMapper mapper;
  48 + private final JdbcTemplate jdbc;
41 49  
42   - public LedgerService(StringRedisTemplate redis, ObjectMapper mapper) {
  50 + public LedgerService(StringRedisTemplate redis, ObjectMapper mapper, JdbcTemplate jdbc) {
43 51 this.redis = redis;
44 52 this.mapper = mapper;
  53 + this.jdbc = jdbc;
45 54 }
46 55  
47   - /** 追加一条事件(绝不抛异常——日志失败不能影响对话主流程)。 */
  56 + /** 追加一条事件(无身份上下文的旧签名:sMakePerson 从 convId 前缀推导)。 */
48 57 public void append(String convId, String type, Map<String, Object> data) {
  58 + append(convId, type, data, null);
  59 + }
  60 +
  61 + /** 追加一条事件(绝不抛异常——账本失败不能影响对话主流程)。 */
  62 + public void append(String convId, String type, Map<String, Object> data, AgentIdentity who) {
49 63 if (convId == null || convId.isBlank()) {
50 64 return;
51 65 }
  66 + String json;
52 67 try {
53 68 Map<String, Object> ev = new LinkedHashMap<>();
54 69 ev.put("t", System.currentTimeMillis());
... ... @@ -56,14 +71,70 @@ public class LedgerService {
56 71 if (data != null) {
57 72 ev.putAll(data);
58 73 }
  74 + json = mapper.writeValueAsString(ev);
  75 + } catch (Exception e) {
  76 + log.warn("ledger serialize failed (conv={}, type={}): {}", convId, type, e.getMessage());
  77 + return;
  78 + }
  79 + persist(convId, type, json, who);
  80 + cache(convId, json);
  81 + }
  82 +
  83 + /** MySQL 权威落库:服务端赋 iSeq/iTurn,唯一索引 + 重试防并发撞号。失败仅告警(Redis 兜底)。 */
  84 + private void persist(String convId, String type, String json, AgentIdentity who) {
  85 + if (jdbc == null) {
  86 + return;
  87 + }
  88 + String maker = who != null && who.userId() != null ? who.userId() : userIdOf(convId);
  89 + String brands = who == null ? null : who.brandsId();
  90 + String sub = who == null ? null : who.subsidiaryId();
  91 + for (int attempt = 0; attempt < 3; attempt++) {
  92 + try {
  93 + int seq = 1;
  94 + int turn = TURN_OPENERS.contains(type) ? 1 : 0;
  95 + List<Map<String, Object>> last = jdbc.queryForList(
  96 + "SELECT iSeq, iTurn FROM ai_chat_event WHERE sConversationId=? ORDER BY iId DESC LIMIT 1",
  97 + convId);
  98 + if (!last.isEmpty()) {
  99 + int lastSeq = ((Number) last.get(0).get("iSeq")).intValue();
  100 + int lastTurn = ((Number) last.get(0).get("iTurn")).intValue();
  101 + seq = lastSeq + 1;
  102 + turn = TURN_OPENERS.contains(type) ? lastTurn + 1 : lastTurn;
  103 + }
  104 + if (turn < 1) {
  105 + turn = 1;
  106 + }
  107 + jdbc.update(
  108 + "INSERT INTO ai_chat_event(sConversationId,iSeq,iTurn,sMakePerson,sBrandsId,sSubsidiaryId,sType,sPayload,tCreateDate) "
  109 + + "VALUES(?,?,?,?,?,?,?,?,NOW())",
  110 + convId, seq, turn, maker, brands, sub, type, json);
  111 + return;
  112 + } catch (org.springframework.dao.DuplicateKeyException dup) {
  113 + // 并发撞号:重读末行再试
  114 + } catch (Exception e) {
  115 + log.warn("ledger mysql append failed (conv={}, type={}): {}", convId, type, e.getMessage());
  116 + return;
  117 + }
  118 + }
  119 + log.warn("ledger mysql append gave up after retries (conv={}, type={})", convId, type);
  120 + }
  121 +
  122 + private void cache(String convId, String json) {
  123 + try {
59 124 String key = PREFIX + convId;
60   - redis.opsForList().rightPush(key, mapper.writeValueAsString(ev));
  125 + redis.opsForList().rightPush(key, json);
61 126 redis.expire(key, TTL);
62 127 } catch (Exception e) {
63   - log.warn("ledger append failed (conv={}, type={}): {}", convId, type, e.getMessage());
  128 + log.warn("ledger redis append failed (conv={}): {}", convId, e.getMessage());
64 129 }
65 130 }
66 131  
  132 + /** convId 形如 {userId}:{local}(ConversationService.scopedId),前缀即用户 id。 */
  133 + private static String userIdOf(String convId) {
  134 + int i = convId.indexOf(':');
  135 + return i > 0 ? convId.substring(0, i) : null;
  136 + }
  137 +
67 138 /** 全量事件(按发生顺序),供前端历史投影。 */
68 139 public List<Map<String, Object>> events(String convId) {
69 140 return read(convId, 0);
... ... @@ -75,28 +146,74 @@ public class LedgerService {
75 146 }
76 147  
77 148 private List<Map<String, Object>> read(String convId, int lastN) {
78   - List<Map<String, Object>> out = new ArrayList<>();
  149 + List<String> raw = null;
79 150 try {
80 151 long start = lastN <= 0 ? 0 : -lastN;
81   - List<String> raw = redis.opsForList().range(PREFIX + convId, start, -1);
82   - if (raw == null) {
83   - return out;
  152 + if (Boolean.TRUE.equals(redis.hasKey(PREFIX + convId))) {
  153 + raw = redis.opsForList().range(PREFIX + convId, start, -1);
84 154 }
85   - for (String s : raw) {
86   - try {
87   - @SuppressWarnings("unchecked")
88   - Map<String, Object> m = mapper.readValue(s, Map.class);
89   - out.add(m);
90   - } catch (Exception ignore) {
  155 + } catch (Exception e) {
  156 + log.warn("ledger redis read failed (conv={}): {}", convId, e.getMessage());
  157 + }
  158 + if (raw == null) {
  159 + raw = coldRead(convId, lastN);
  160 + }
  161 + List<Map<String, Object>> out = new ArrayList<>();
  162 + for (String s : raw) {
  163 + try {
  164 + @SuppressWarnings("unchecked")
  165 + Map<String, Object> m = mapper.readValue(s, Map.class);
  166 + out.add(m);
  167 + } catch (Exception ignore) {
  168 + }
  169 + }
  170 + return out;
  171 + }
  172 +
  173 + /** Redis 缓存失效 → 回源 MySQL(有界窗口)并回填缓存。 */
  174 + private List<String> coldRead(String convId, int lastN) {
  175 + if (jdbc == null) {
  176 + return List.of();
  177 + }
  178 + int window = lastN <= 0 ? COLD_READ_MAX : Math.min(lastN, COLD_READ_MAX);
  179 + try {
  180 + List<Map<String, Object>> rows = jdbc.queryForList(
  181 + "SELECT sType, sPayload FROM ai_chat_event WHERE sConversationId=? ORDER BY iId DESC LIMIT " + window,
  182 + convId);
  183 + if (rows.isEmpty()) {
  184 + return List.of();
  185 + }
  186 + List<String> out = new ArrayList<>(rows.size());
  187 + for (Map<String, Object> r : rows) {
  188 + if ("deleted".equals(String.valueOf(r.get("sType")))) {
  189 + break; // 删除标记:更早的事件属于已删除的会话,不再回源
91 190 }
  191 + Object p = r.get("sPayload");
  192 + if (p != null) {
  193 + out.add(p.toString());
  194 + }
  195 + }
  196 + if (out.isEmpty()) {
  197 + return List.of();
92 198 }
  199 + Collections.reverse(out);
  200 + try { // 回填热缓存(尽力而为)
  201 + String key = PREFIX + convId;
  202 + redis.opsForList().rightPushAll(key, out);
  203 + redis.expire(key, TTL);
  204 + } catch (Exception ignore) {
  205 + }
  206 + return out;
93 207 } catch (Exception e) {
94   - log.warn("ledger read failed (conv={}): {}", convId, e.getMessage());
  208 + log.warn("ledger mysql read failed (conv={}): {}", convId, e.getMessage());
  209 + return List.of();
95 210 }
96   - return out;
97 211 }
98 212  
99   - /** 日志键是否存在(供会话列表懒清理:内容已过期的会话项从列表剔除)。 */
  213 + /**
  214 + * 会话在热缓存或持久层是否有内容(供会话列表懒清理)。MySQL 里的事件永不删除,
  215 + * 因此这里只看 Redis 热缓存:30 天没动过的会话从侧栏消失,但账本仍可溯源。
  216 + */
100 217 public boolean exists(String convId) {
101 218 try {
102 219 return Boolean.TRUE.equals(redis.hasKey(PREFIX + convId));
... ... @@ -105,10 +222,15 @@ public class LedgerService {
105 222 }
106 223 }
107 224  
  225 + /**
  226 + * 删除会话 = 清热缓存 + 在持久层落一条 {@code deleted} 标记(append-only,旧事件永久保留可溯源,
  227 + * 但冷读止于标记——同名会话再启用时不会复活已删除的历史)。
  228 + */
108 229 public void delete(String convId) {
109 230 try {
110 231 redis.delete(PREFIX + convId);
111 232 } catch (Exception ignore) {
112 233 }
  234 + persist(convId, "deleted", "{\"type\":\"deleted\"}", null);
113 235 }
114 236 }
... ...
src/main/java/com/xly/service/TokenEstimator.java 0 → 100644
  1 +package com.xly.service;
  2 +
  3 +import dev.langchain4j.data.message.AiMessage;
  4 +import dev.langchain4j.data.message.ChatMessage;
  5 +import dev.langchain4j.data.message.SystemMessage;
  6 +import dev.langchain4j.data.message.ToolExecutionResultMessage;
  7 +import dev.langchain4j.data.message.UserMessage;
  8 +import dev.langchain4j.agent.tool.ToolExecutionRequest;
  9 +
  10 +import java.util.List;
  11 +
  12 +/**
  13 + * 本地保守 token 估算器(不依赖任何模型请求)。
  14 + *
  15 + * <p>规则:CJK/全角字符 ≈ 1 token/字;其余字符 ≈ 3 字符/token。**故意高估**——
  16 + * 高估只会少带几轮历史,低估会让 Ollama 从前面静默截头(system prompt 先死),两者代价不对称。
  17 + * 校准回路:每次响应免费自带 prompt_eval_count,{@code TracingChatModelListener} 记录
  18 + * 估算 vs 实际,实际逼近 num_ctx 即告警。
  19 + */
  20 +public final class TokenEstimator {
  21 +
  22 + /** 每条消息的结构开销(role/分隔符等)。 */
  23 + public static final int MSG_OVERHEAD = 8;
  24 +
  25 + private TokenEstimator() {
  26 + }
  27 +
  28 + public static int estimate(String s) {
  29 + if (s == null || s.isEmpty()) {
  30 + return 0;
  31 + }
  32 + int cjk = 0;
  33 + int other = 0;
  34 + for (int i = 0; i < s.length(); i++) {
  35 + if (s.charAt(i) >= 0x2E80) {
  36 + cjk++;
  37 + } else {
  38 + other++;
  39 + }
  40 + }
  41 + return cjk + (other + 2) / 3;
  42 + }
  43 +
  44 + public static int estimate(ChatMessage m) {
  45 + if (m == null) {
  46 + return 0;
  47 + }
  48 + int n = MSG_OVERHEAD;
  49 + if (m instanceof SystemMessage sm) {
  50 + n += estimate(sm.text());
  51 + } else if (m instanceof UserMessage um) {
  52 + n += estimate(um.hasSingleText() ? um.singleText() : String.valueOf(um.contents()));
  53 + } else if (m instanceof AiMessage am) {
  54 + n += estimate(am.text());
  55 + if (am.hasToolExecutionRequests()) {
  56 + for (ToolExecutionRequest r : am.toolExecutionRequests()) {
  57 + n += estimate(r.name()) + estimate(r.arguments()) + 6;
  58 + }
  59 + }
  60 + } else if (m instanceof ToolExecutionResultMessage tr) {
  61 + n += estimate(tr.text()) + estimate(tr.toolName());
  62 + } else {
  63 + n += estimate(String.valueOf(m));
  64 + }
  65 + return n;
  66 + }
  67 +
  68 + public static int estimate(List<ChatMessage> messages) {
  69 + int n = 0;
  70 + for (ChatMessage m : messages) {
  71 + n += estimate(m);
  72 + }
  73 + return n;
  74 + }
  75 +}
... ...
src/main/java/com/xly/web/AgentChatController.java
... ... @@ -95,7 +95,7 @@ public class AgentChatController {
95 95 final String convId = conversations.scopedId(identity, req.conversationId);
96 96  
97 97 conversations.touch(identity.userId(), convId, userInput);
98   - ledger.append(convId, "user", Map.of("text", userInput));
  98 + ledger.append(convId, "user", Map.of("text", userInput), identity);
99 99  
100 100 exec.submit(() -> {
101 101 try {
... ...
src/main/resources/application.yml
... ... @@ -68,6 +68,9 @@ llm:
68 68 base-url: ${LLM_BASE_URL:http://112.82.245.194:41434/v1}
69 69 api-key: ${LLM_API_KEY:ollama} # Ollama 不校验(任意非空);云端填真实 key
70 70 chat-model: ${LLM_CHAT_MODEL:qwen3.6-27b-iq3:latest}
  71 + # 镜像 Ollama 侧 OLLAMA_CONTEXT_LENGTH(OpenAI 兼容通道无法逐请求传 num_ctx)。
  72 + # 投影层以它为预算基数;两值漂移由 prompt_eval_count 校准回路兜底(TracingChatModelListener 告警)。
  73 + context-length: ${LLM_CONTEXT_LENGTH:16384}
71 74  
72 75 erp:
73 76 baseurl: ${ERP_BASEURL:http://118.178.19.35:8080/xlyEntry_saas}
... ...
src/test/java/com/xly/service/ConversationScopeTest.java
... ... @@ -26,7 +26,7 @@ class ConversationScopeTest {
26 26 }
27 27  
28 28 private static AgentIdentity user(String id) {
29   - return new AgentIdentity("tok", id, "brand", null);
  29 + return new AgentIdentity("tok", id, "brand", "sub", null);
30 30 }
31 31  
32 32 @Test
... ...
src/test/java/com/xly/service/EventLogConcurrencyTest.java
... ... @@ -30,7 +30,7 @@ class EventLogConcurrencyTest {
30 30 void concurrentStreamAndConfirmLoseNothing() throws Exception {
31 31 InMemoryLedger log = new InMemoryLedger();
32 32 EventLogChatMemory mem = new EventLogChatMemory(
33   - CONV, log, new EventProjectionService(new ObjectMapper()), 6000, false);
  33 + CONV, log, new EventProjectionService(new ObjectMapper()), null, false);
34 34  
35 35 int writers = 4;
36 36 int perWriter = 100;
... ...
src/test/java/com/xly/service/EventProjectionTest.java
... ... @@ -34,7 +34,7 @@ class EventProjectionTest {
34 34 }
35 35  
36 36 private EventLogChatMemory memory(InMemoryLedger log, boolean internal) {
37   - return new EventLogChatMemory(CONV, log, proj, 6000, internal);
  37 + return new EventLogChatMemory(CONV, log, proj, null, internal);
38 38 }
39 39  
40 40 private static AiMessage toolCall(String id, String name, String args) {
... ... @@ -92,6 +92,8 @@ class EventProjectionTest {
92 92  
93 93 @Test
94 94 void turnsBeyondBudgetBecomeDeterministicDigest() {
  95 + // 小上下文(6000 → promptBudget≈2976t),每轮约 1500t:只装得下 1-2 轮原文,其余进摘要区
  96 + EventProjectionService small = new EventProjectionService(new ObjectMapper(), 6000);
95 97 InMemoryLedger log = new InMemoryLedger();
96 98 for (int i = 1; i <= 10; i++) {
97 99 log.append(CONV, "user", Map.of("text", "问题" + i + ":" + "长".repeat(1500)));
... ... @@ -99,7 +101,7 @@ class EventProjectionTest {
99 101 }
100 102 log.append(CONV, "user", Map.of("text", "当前问题"));
101 103  
102   - List<ChatMessage> ms = proj.project("SYS", log.events(CONV), 6000);
  104 + List<ChatMessage> ms = small.project("SYS", log.events(CONV));
103 105 UserMessage digest = (UserMessage) ms.stream()
104 106 .filter(m -> m instanceof UserMessage u && u.singleText().startsWith("【更早对话摘要"))
105 107 .findFirst().orElseThrow();
... ... @@ -107,9 +109,32 @@ class EventProjectionTest {
107 109 assertTrue(digest.singleText().contains("⇒ 回答1"), "摘要含结果");
108 110 long verbatimUsers = ms.stream().filter(m -> m instanceof UserMessage u
109 111 && !u.singleText().startsWith("【更早对话摘要")).count();
110   - assertTrue(verbatimUsers <= 5, "近期原文区按 6000 字预算收拢(每轮约 1500 字)");
  112 + assertTrue(verbatimUsers <= 3, "近期原文区按 token 预算收拢(每轮约 1500t)");
111 113 assertTrue(ms.get(ms.size() - 1) instanceof UserMessage u && u.singleText().equals("当前问题"),
112 114 "当前轮永在末尾且完整");
  115 + assertTrue(TokenEstimator.estimate(ms) <= small.promptBudget(), "发送前自检:总量不超预算");
  116 + }
  117 +
  118 + @Test
  119 + void currentTurnDemotesOldestToolResultsWhenOversized() {
  120 + // 当前轮 4 个各约 1200t 的工具结果 + 小上下文:最早的降 120 字摘要,最近 2 个保全文,配对不破坏
  121 + EventProjectionService small = new EventProjectionService(new ObjectMapper(), 8000);
  122 + InMemoryLedger log = new InMemoryLedger();
  123 + EventLogChatMemory mem = new EventLogChatMemory(CONV, log, small, null, false);
  124 + log.append(CONV, "user", Map.of("text", "查四张表"));
  125 + for (int i = 1; i <= 4; i++) {
  126 + mem.add(toolCall("t" + i, "readFormData", "{\"formId\":\"F" + i + "\"}"));
  127 + mem.add(ToolExecutionResultMessage.from("t" + i, "readFormData", "行".repeat(1200)));
  128 + }
  129 + mem.add(SystemMessage.from("SYS"));
  130 + List<ChatMessage> ms = mem.messages();
  131 + List<ToolExecutionResultMessage> trs = ms.stream()
  132 + .filter(m -> m instanceof ToolExecutionResultMessage)
  133 + .map(m -> (ToolExecutionResultMessage) m).toList();
  134 + assertEquals(4, trs.size(), "工具调用/结果配对一个不丢");
  135 + assertTrue(trs.get(0).text().length() <= EventProjectionService.TOOL_DIGEST_LEN + 1, "最早结果降摘要");
  136 + assertEquals(1200, trs.get(3).text().length(), "最近结果保全文");
  137 + assertTrue(TokenEstimator.estimate(ms) <= small.promptBudget(), "轮内让位后不超预算");
113 138 }
114 139  
115 140 @Test
... ... @@ -138,7 +163,7 @@ class EventProjectionTest {
138 163 log.append(CONV, "ai", Map.of("text", "大约有 1286 个客户。"));
139 164 memory(log, true).add(UserMessage.from("你上一条回答没有调用任何工具,请先查询真实数据。"));
140 165  
141   - List<ChatMessage> ms = proj.project("SYS", log.events(CONV), 6000);
  166 + List<ChatMessage> ms = proj.project("SYS", log.events(CONV));
142 167 assertTrue(ms.stream().anyMatch(m -> m instanceof UserMessage u
143 168 && u.singleText().contains("没有调用任何工具")), "注入话术 LLM 可见");
144 169  
... ... @@ -160,7 +185,7 @@ class EventProjectionTest {
160 185 assertTrue(hist.get(1).get("content").startsWith("【待确认】"));
161 186 assertTrue(hist.get(2).get("content").startsWith("【已执行】"));
162 187  
163   - List<ChatMessage> ms = proj.project("SYS", log.events(CONV), 6000);
  188 + List<ChatMessage> ms = proj.project("SYS", log.events(CONV));
164 189 assertTrue(ms.stream().anyMatch(m -> m instanceof UserMessage u && u.singleText().contains("提交「报价」")));
165 190 assertTrue(ms.stream().anyMatch(m -> m instanceof AiMessage a && a.text().contains("已生成待确认提议")));
166 191 assertTrue(ms.stream().anyMatch(m -> m instanceof AiMessage a && a.text().contains("操作执行成功")));
... ... @@ -197,7 +222,7 @@ class EventProjectionTest {
197 222 "{\"type\":\"form_collect\",\"entity\":\"报价\",\"message\":\"请填写\"}"));
198 223 String card = proj.activeCard(log.events(CONV));
199 224 assertTrue(card != null && card.contains("「报价」"), "弹表单后流程卡钉住");
200   - assertTrue(proj.project("SYS", log.events(CONV), 6000).get(0).toString().contains("进行中的流程"));
  225 + assertTrue(proj.project("SYS", log.events(CONV)).get(0).toString().contains("进行中的流程"));
201 226  
202 227 // 中途插入无关查询轮 → 卡不摘
203 228 log.append(CONV, "user", Map.of("text", "先查下必胜客电话"));
... ... @@ -241,7 +266,7 @@ class EventProjectionTest {
241 266 assertEquals(5, hist.size(), "tool 摘要事件不进历史,其余照旧");
242 267 assertTrue(hist.get(3).get("content").contains("【表单】新建报价"));
243 268  
244   - List<ChatMessage> ms = proj.project("SYS", log.events(CONV), 6000);
  269 + List<ChatMessage> ms = proj.project("SYS", log.events(CONV));
245 270 assertTrue(ms.stream().anyMatch(m -> m instanceof AiMessage a && a.text().contains("请问改成什么号码")));
246 271 assertFalse(ms.stream().anyMatch(m -> m.toString().contains("findForms")), "旧 tool 摘要不进 LLM 正文");
247 272 }
... ...
src/test/java/com/xly/service/InMemoryLedger.java
1 1 package com.xly.service;
2 2  
3 3 import com.fasterxml.jackson.databind.ObjectMapper;
  4 +import com.xly.agent.AgentIdentity;
4 5  
5 6 import java.util.ArrayList;
6 7 import java.util.Collections;
... ... @@ -9,7 +10,7 @@ import java.util.List;
9 10 import java.util.Map;
10 11  
11 12 /**
12   - * 测试替身:内存版事件日志。append 与 Redis rightPush 同为「整条原子追加」,
  13 + * 测试替身:内存版事件账本。append 与生产(MySQL insert + Redis rightPush)同为「整条原子追加」,
13 14 * 用于验证投影逻辑与「任意并发写者只追加、绝无整包读改写」的设计性质。
14 15 */
15 16 class InMemoryLedger extends LedgerService {
... ... @@ -18,11 +19,11 @@ class InMemoryLedger extends LedgerService {
18 19 private final ObjectMapper mapper = new ObjectMapper();
19 20  
20 21 InMemoryLedger() {
21   - super(null, new ObjectMapper());
  22 + super(null, new ObjectMapper(), null);
22 23 }
23 24  
24 25 @Override
25   - public void append(String convId, String type, Map<String, Object> data) {
  26 + public void append(String convId, String type, Map<String, Object> data, AgentIdentity who) {
26 27 Map<String, Object> ev = new LinkedHashMap<>();
27 28 ev.put("t", 0L);
28 29 ev.put("type", type);
... ...