mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 05:41:57 +00:00
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:
@@ -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}"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
@@ -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, "没把记了什么带出去,只报条数等于没说"
|
||||
|
||||
@@ -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 取数那条路早就能下钻到工件
|
||||
|
||||
@@ -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 一律失败,
|
||||
|
||||
Reference in New Issue
Block a user