EventLogChatMemory.java
6.92 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
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
package com.xly.agent;
import com.xly.service.EventProjectionService;
import com.xly.service.LedgerService;
import dev.langchain4j.data.message.AiMessage;
import dev.langchain4j.data.message.ChatMessage;
import dev.langchain4j.agent.tool.ToolExecutionRequest;
import dev.langchain4j.data.message.SystemMessage;
import dev.langchain4j.data.message.ToolExecutionResultMessage;
import dev.langchain4j.data.message.UserMessage;
import dev.langchain4j.memory.ChatMemory;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 事件账本上的对话记忆:{@link #add} 把模型环消息逐条 append 成事件(追加原子——
* 对话流与按钮端点并发写互不覆盖),{@link #messages()} 读取
* {@link EventProjectionService} 的四段式 token 记账投影。账本即唯一事实源,本类不持有任何会话状态。
*
* <p>写者分工:用户事件由控制器在收到请求时先落账(前端立即可见),本类对 UserMessage 做
* 去重跳过;{@code internalUserTurn=true} 时(反编造护栏重试的注入话术)例外——落一条
* {@code internal} 标记的用户事件,LLM 可见、前端历史不显示。模型消息(tool_call/tool_result/ai)
* 只由本类落账。
*
* <p>tool_call 事件存**中立格式** {@code calls=[{id,name,args}]}(脱 LangChain4j 序列化耦合),
* 旧版 {@code payload} 载荷投影层仍可读。
*/
public class EventLogChatMemory implements ChatMemory {
private static final int LLM_EVENT_WINDOW = 400;
private final String convId;
private final LedgerService log;
private final EventProjectionService projection;
private final AgentIdentity identity;
private final boolean internalUserTurn;
/** system prompt 每次由 provider 提供,不落账(保持账本纯业务事件)。 */
private volatile String systemText;
/**
* 本轮实际喂给模型的用户文本(原话 + 编排层附加的后缀)。账本只存原话;
* 投影时把当前轮用户消息替换为它。P2 编排层不再加后缀后,此字段恒空。
*/
private volatile String currentTurnUserText;
public EventLogChatMemory(String convId, LedgerService log, EventProjectionService projection,
AgentIdentity identity, boolean internalUserTurn) {
this.convId = convId;
this.log = log;
this.projection = projection;
this.identity = identity;
this.internalUserTurn = internalUserTurn;
}
@Override
public Object id() {
return convId;
}
@Override
public void add(ChatMessage m) {
if (m instanceof SystemMessage sm) {
systemText = sm.text();
return;
}
if (m instanceof UserMessage um) {
String text = um.hasSingleText() ? um.singleText() : String.valueOf(um.contents());
if (!internalUserTurn && alreadyLoggedPrefixOf(text)) {
currentTurnUserText = text;
return;
}
Map<String, Object> data = new LinkedHashMap<>();
data.put("text", text);
if (internalUserTurn) {
data.put("internal", true);
}
log.append(convId, "user", data, identity);
return;
}
if (m instanceof AiMessage am) {
if (am.hasToolExecutionRequests()) {
Map<String, Object> data = new LinkedHashMap<>();
data.put("text", am.text() == null ? "" : am.text());
List<Map<String, Object>> calls = new ArrayList<>();
List<String> tools = new ArrayList<>();
for (ToolExecutionRequest r : am.toolExecutionRequests()) {
Map<String, Object> c = new LinkedHashMap<>();
c.put("id", r.id() == null ? "" : r.id());
c.put("name", r.name() == null ? "" : r.name());
c.put("args", r.arguments() == null ? "" : r.arguments());
calls.add(c);
tools.add(r.name());
}
data.put("calls", calls);
data.put("tools", tools);
log.append(convId, "tool_call", data, identity);
} else if (am.text() != null && !am.text().isBlank()) {
log.append(convId, "ai", Map.of("text", am.text()), identity);
}
return;
}
if (m instanceof ToolExecutionResultMessage tr) {
String text = tr.text() == null ? "" : tr.text();
Map<String, Object> data = new LinkedHashMap<>();
data.put("tcId", tr.id() == null ? "" : tr.id());
data.put("name", tr.toolName() == null ? "" : tr.toolName());
data.put("text", text);
data.put("digest", digest(text));
log.append(convId, "tool_result", data, identity);
}
}
/**
* 控制器已把本轮用户**原话**落账(最近的用户事件是喂给模型文本的前缀、且其后无模型事件)
* → 跳过,避免重复。编排层可能在原话后附加后缀,故用前缀而非全等判定。
*/
private boolean alreadyLoggedPrefixOf(String text) {
List<Map<String, Object>> tail = log.events(convId, 6);
for (int i = tail.size() - 1; i >= 0; i--) {
String type = String.valueOf(tail.get(i).get("type"));
switch (type) {
case "user":
String logged = String.valueOf(tail.get(i).get("text"));
return !logged.isEmpty() && text.startsWith(logged);
case "form_submit", "ai", "tool_call", "tool_result", "assistant":
return false;
default:
// queued/confirm 等按钮事件可能插在中间,继续往前找
}
}
return false;
}
@Override
public List<ChatMessage> messages() {
List<ChatMessage> out = projection.project(systemText, log.events(convId, LLM_EVENT_WINDOW));
String full = currentTurnUserText;
if (full != null) {
// 当前轮用户消息换成实际喂给模型的完整文本(原话+编排后缀)
for (int i = out.size() - 1; i >= 0; i--) {
if (out.get(i) instanceof UserMessage u && u.hasSingleText()
&& full.startsWith(u.singleText())) {
out.set(i, UserMessage.from(full));
break;
}
}
}
return out;
}
/** 账本是唯一事实源,不因模型环异常清史;删除会话走 ConversationService 级联。 */
@Override
public void clear() {
}
private static String digest(String text) {
String t = text.replace('\n', ' ').trim();
return t.length() > EventProjectionService.TOOL_DIGEST_LEN
? t.substring(0, EventProjectionService.TOOL_DIGEST_LEN) + "…" : t;
}
}