Copilot 会自己记该记的了;顺带让写记忆这件事看得见

以前只有用户明说「记住」才会写:17 轮有图的对话里只有 4 轮带记忆节点,全在
那个用户主动谈记忆的会话里。于是他说过一次「我叫张三」,换个话题再问就
谁也不知道了。

现在 answer 路径多一步判断:这句话里有没有值得长期记住的东西,有就在主链路
前面加一个 memory(write) 节点。边界比指令本身要紧,所以写死了四类不该记的:
这一轮的问题本身、查出来的数(会过期且能重查)、一次性指令、以及**用户没说过
而你推测出来的**。另外两条是从召回的实际情况倒推的——content 要脱离上下文也
成立(召回时没有这轮对话,「他说他叫张三」读起来不知道「他」是谁),以及
拿不准就不记:记错了污染以后每一次对话,漏记一条用户再说一遍就是了。

**动手前先修了一个前提**:写记忆在界面上是隐形的。memory 节点发的是 info 级
日志,而解码层明确丢弃所有 info(decode.ts),也就是说自动记忆 = 系统背着
用户往一个跨会话、长期有效的地方存关于他的信息,而他看不见。

新增 memory.end 事件,解码层按动作分开渲染:写入把**记了什么**原样显示
(只报「写入 1 条」等于没说,用户没法判断它记的是不是他想让它记的)、召回说
「想起 N 条」、清空标成警告。

真模型验过(deepseek-v4-pro):

「我平时叫小蒋,以后报表金额用万元」
  → content='用户平时叫小蒋,他要求以后报表里的金额统一用万元作单位'
    scope 未写、跑完落库、事件里带着内容
  → 换个说法召回得到:「该怎么称呼我」「金额用什么单位」都命中

三条对照确认不滥记:
  shop 里有几家店        → 没记
  重来                    → 走 reply,连图都没建
  把上一轮的结果按月份排一下  → 没记
This commit is contained in:
ThearchyHelios
2026-09-19 22:41:27 +08:00
parent 33c7c41336
commit a027261701
6 changed files with 125 additions and 2 deletions
+14
View File
@@ -266,6 +266,20 @@ def _user_message(payload: GenerateIn, *, patch: bool) -> str:
"3. **建图但不执行**(done 的 run 为 false):用户要的是**流程本身**"
"——「设计一个工作流」「做一个每天能跑的」这类。他要的是这张图,"
"不是这一次的结果,跑一遍只是替他多花一次钱。\n"
"\n"
# 以前只有用户明说"记住"才会写记忆:17 轮里只有 4 轮带记忆节点,
# 全在用户主动谈记忆的那个会话里。于是他说过一次"我叫张三",
# 换个话题再问就谁也不知道了
"顺带判断这句话里有没有**值得长期记住**的东西。有就在主链路前面加一个\n"
"memory 节点(action=write,紧跟 input),没有就别加:\n"
"- 该记:用户是谁、怎么称呼、长期偏好;口径和约定(「我们说的活跃指"
"当月有订单」);用户的纠正(「不对,管理员要按 platform 算」);"
"稳定的环境事实(「后端跑在 8001」)\n"
"- 不该记:这一轮的问题本身;查出来的数和结果(会过期,而且能重查);"
"一次性的指令(「重来」「继续」);**用户没说过、你推测出来的东西**\n"
"- content 要写成一句脱离上下文也成立的陈述——召回时没有这轮对话,"
"「他说他叫张三」这种读起来就不知道「他」是谁\n"
"- 拿不准就不记。记错了会污染以后每一次对话,而漏记一条,用户再说一遍就是了\n"
)
return f"请设计一个工作流:{payload.instruction}"
+3
View File
@@ -42,6 +42,9 @@ class EventType(StrEnum):
# 知识检索。以前只发一条 info 日志,于是"这句结论依据的是哪份文档的哪一段"
# 在轨迹里查不到——而 SQL 取数那条路早就有工件快照可以下钻
RETRIEVE_END = "retrieve.end"
# 记忆读写。以前发的是 info 日志,而解码器丢弃所有 info——于是往长期记忆里
# 写东西这件事在界面上是隐形的。让 Copilot 主动记之后,这条不可接受
MEMORY_END = "memory.end"
SANDBOX_START = "sandbox.start"
SANDBOX_END = "sandbox.end"
+10 -2
View File
@@ -52,8 +52,16 @@ async def run_memory(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
else:
raise NodeError(ctx.node.id, f"未知的记忆操作:{action}")
ctx.emit(EventType.LOG, level="info",
message=f"记忆 {action} @ {scope}:{result.get('count', result.get('cleared', 1))} 条")
# 写入必须看得见:这是往长期记忆里存东西,会影响以后每一次对话。
# 以前发的是 info 日志,而解码层丢弃所有 info,等于系统背着用户记了东西
ctx.emit(
EventType.MEMORY_END,
action=action,
scope=scope,
count=result.get("count", result.get("cleared", 1 if action == "write" else 0)),
# 记了什么要原样说出来,只报"写了 1 条"等于没说
content=result.get("content", "")[:300],
)
updates: dict[str, Any] = {"nodes": {ctx.node.id: result}}
var_name = ctx.cfg("assign_to", "")
+43
View File
@@ -452,3 +452,46 @@ async def test_copilot_is_told_not_to_invent_a_memory_scope() -> None:
assert memory_line, "节点参考里找不到 memory 这一行"
assert "scope" not in memory_line, "scope 还列在可填字段里,模型就会去填它"
assert "不要写 scope" in NODE_REFERENCE
# --------------------------------------------------------------------------
# 主动记忆
# --------------------------------------------------------------------------
def test_copilot_is_told_when_to_record_a_memory() -> None:
"""以前只有用户明说「记住」才会写:17 轮里只有 4 轮带记忆节点,全在用户
主动谈记忆的那个会话里。于是他说过一次「我叫张三」,换个话题再问就
谁也不知道了。
但边界比指令本身更要紧——记错了会污染以后每一次对话。
"""
from app.api.copilot import GenerateIn, _user_message
text = _user_message(GenerateIn(instruction="随便问问", intent="answer"), patch=False)
assert "该记" in text and "不该记" in text
# 最容易被记进去的垃圾:这一轮的问题、查出来的数、一次性指令、以及推测
for junk in ("这一轮的问题本身", "查出来的数", "一次性的指令", "推测出来的东西"):
assert junk in text, f"没有拦住「{junk}」这类"
assert "拿不准就不记" in text
async def test_writing_a_memory_is_visible_in_the_trace(client, monkeypatch) -> None:
"""自动记忆的前提是看得见。
以前 memory 节点发的是 info 日志,而解码层丢弃所有 info——等于系统背着
用户往长期记忆里存东西。让 Copilot 主动记之后这条不可接受。
"""
from app.core.events import EventType
assert EventType.MEMORY_END == "memory.end"
# 节点确实发这条事件,而且带上了记了什么
import inspect
from app.engine.nodes import knowledge as node
src = inspect.getsource(node.run_memory)
assert "MEMORY_END" in src
assert "LOG, level=\"info\"" not in src, "又退回 info 日志了,界面上会看不见"
assert 'result.get("content"' in src, "没把记了什么带出去,只报条数等于没说"
+31
View File
@@ -712,6 +712,37 @@ export function decodeRun(events: RunEvent[]): Step[] {
break
}
case 'memory.end': {
// 写入要把**记了什么**原样显示出来。只说"写入 1 条"等于没说——
// 用户没法判断系统记的是不是他想让它记的,而这东西会影响以后每次对话
const action = String(d.action ?? '')
const count = num(d.count) ?? 0
const content = String(d.content ?? '')
if (action === 'write') {
push({
id: `mw-${seq}`, seq, kind: 'note', nodeId, status: 'done',
title: content ? `记住了:${content.slice(0, 40)}${content.length > 40 ? '…' : ''}`
: '写入了一条记忆',
detail: content || undefined,
}, nodeId)
break
}
if (action === 'clear') {
push({
id: `mc-${seq}`, seq, kind: 'note', nodeId, status: 'done', level: 'warn',
title: `清空了记忆域「${String(d.scope ?? '')}」的 ${count} 条`,
}, nodeId)
break
}
push({
id: `mr-${seq}`, seq, kind: 'schema', nodeId,
status: count ? 'done' : 'failed',
level: count ? undefined : 'warn',
title: count ? `想起 ${count} 条相关记忆` : '没有想起相关的记忆',
}, nodeId)
break
}
case 'retrieve.end': {
// 知识检索。以前只发一条 info 日志,于是"这句结论依据的是哪份文档的
// 哪一段"在轨迹里查不到——而 SQL 取数那条路早就能下钻到工件
+24
View File
@@ -71,6 +71,30 @@ console.log('\n=== 知识检索 ===')
check('未知事件不被静默吞掉', flatten(unknown).some((x) => x.title === 'brand.new'))
}
console.log('\n=== 长期记忆 ===')
{
// 写记忆以前只发 info 日志,而 info 在这一层是被丢弃的——系统往长期记忆里
// 存东西,界面上一点痕迹都没有。Copilot 会主动记之后,这条不可接受。
const ev = (data) => [{ seq: 1, type: 'memory.end', node_id: 'mem', data }]
const w = flatten(mod.decodeRun(ev({
action: 'write', scope: 'default', count: 1,
content: '用户姓名:张三;工作单位:示例科技',
})))[0]
check('写记忆看得见', !!w)
check('把记了什么说出来,不是只报条数',
!!w?.title?.includes('张三'), w?.title)
check('全文留在详情里', !!w?.detail?.includes('示例科技'))
const r = flatten(mod.decodeRun(ev({ action: 'recall', scope: 'default', count: 3 })))[0]
check('召回说人话', !!r?.title?.includes('想起 3 条'), r?.title)
const none = flatten(mod.decodeRun(ev({ action: 'recall', scope: 'default', count: 0 })))[0]
check('没想起来要标出来,不是静悄悄地过去', none?.status === 'failed')
const c = flatten(mod.decodeRun(ev({ action: 'clear', scope: 'default', count: 7 })))[0]
check('清空是破坏性的,标成警告', c?.level === 'warn' && c.title.includes('7'))
}
console.log('\n=== 被截断的结果集 ===')
{
// 后端按字符数硬切预览,切点落在 JSON 中间是常态。严格 JSON.parse 一律失败,