Agent 偏好记忆怎样确认写入:范围、版本与撤销的一个最小例

Agent 偏好记忆怎样确认写入:范围、版本与撤销的一个最小例

用户说:"这周项目 A 的文档先用英文。"模型提取出"用户偏好英文",这个提取结果即使语义通顺,也还缺少决定能否长期保存的信息:仅限本周还是以后都用?仅项目 A 还是全部项目?用户是在描述临时做法,还是要求系统保存偏好?

真正需要确认的是一项具体变更,而不是让用户对一句"是否启用记忆"作笼统选择。本文把问题收敛到同一用户、两个项目的一项回复语言偏好:候选怎样变成有效记录,迟到确认怎样避免覆盖纠正,到期或撤销后怎样停止使用。所有身份、事件与时间都是构造数据,没有调用模型,也没有接触真实用户记忆。

一次确认应当绑定什么

"用户明确说过"与"用户明确要求保存"分别回答来源和保存意图。在本文采用的保守产品策略中,两者都成立才保存跨会话偏好。用户在当前任务说"用英文回答"可以立即影响当前回答,但不自动获得以后所有会话的保存许可。这是一项本文示例策略,不声称所有记忆产品都采用同样交互。

确认界面可以直接显示:"将项目 A 的回复语言保存为英文,有效至本周末;替代你上次确认的版本 1。"其后台绑定内容应包含主体、项目、偏好键、值、有效期、来源事件,以及被替代的当前版本。若用户确认前页面显示的值或作用域被改了,旧确认不能继续使用。

模型推断"他可能更喜欢英文"只产生候选。候选置信度即使很高,也不能代替上述确认。用户说"不是这个意思"时,系统需要撤掉候选或纠正已保存项,不能把模型摘要当成比用户纠正更强的依据。

这里的"用户纠正优先"只针对该用户有权修改的偏好。组织规定的审批流程是另一类对象;普通用户不能通过"记住:以后跳过审批"修改程序规则。程序规则是否可变,应由规则所有者及其发布权限判断。

先判断范围,再判断冲突

本文的偏好键由四段组成:(租户, 用户, 项目, 偏好名)。项目 A 的中文与项目 B 的英文并不矛盾;另一个用户的同名偏好也不是可用的补充。只有同一个完整键内,新旧值才需要比较版本。

LangGraph 的 Memory overview区分线程范围的短期状态和跨线程长期记忆,并以 namespace 组织后者。这支持"先明确读取范围"的概念;它不替应用完成身份认证,也没有替本文规定保存确认或版本协议。

为了让冲突可演算,示例采用一个明确规则:每次变更带上确认时看见的版本,写入时必须与当前版本相等。用户已经把版本 1 的中文纠正成版本 2 的英文,后来到达的"把版本 1 改成中文"的旧确认会被拒绝。此时应展示当前版本,让用户重新决定,不能自动把旧确认套到新版本。

这是版本比较的用途。它并不判断偏好本身是真是假,也不是凭版本号较大就相信模型的新推断。来源资格和确认先成立,版本比较才有意义。

确认的对象变了,"同意"就不能继续沿用

把"是否保存偏好"放成一个通用开关,会掩盖具体值的变化。假设确认页显示的是"项目 A,本周用英文",用户点击前,后台总结又把候选改成"所有项目,长期用英文"。即使两条记录都叫语言偏好,第一次看到的内容也没有包含第二次的范围与保留期。一个通用的 confirmed=true 无法说明用户到底同意了哪个对象。

因此,本例希望确认绑定的是一份具体变更:哪个用户、哪个项目、哪个偏好键、保存什么值、到什么时候、来源事件是什么,以及确认页面当时看见了哪个版本。这里没有要求每个普通对话都弹窗,而是要求系统能解释保存许可是怎样获得的。产品可以采用明确的"记住这个设置"操作,但不能拿一次模糊确认覆盖后来自动扩大范围的内容。

确认页面还应区分"替换已保存偏好"和"只对当前回答生效"。这两个操作的值可能都是英文,时间范围却完全不同。把当前指令当作当前指令执行,不需要先强迫用户建立长期画像;跨会话保存则需要在本例策略下确认。对于只用一次的临时要求,不建立长期记忆往往更省事,也减少以后纠正的负担。

代码把 explicit 与 confirmed 分成两个布尔输入,就是为了让这个区别可以单独测试。第一条构造记录是模型推断,第二条是用户明确表达但尚未确认保存,两条都拒写。它没有实现自然语言意图识别,因此不能从这两个断言推出系统已经能准确识别所有"请记住"和"这次先用"的表达。实际接入时,这个识别问题仍需独立验证。

用四种请求区分作用域问题和版本问题

第一种请求是用户在项目 A 保存中文。绑定范围和候选范围都为 A,当前没有记录,预期版本为零,这次变更可以创建 v1。这里的零只表示示例没有历史项,不是现实数据库必须采用的版本规范。关键是创建之前和创建之后,客户端对当前记录的理解能够被检查。

第二种请求是同一用户在项目 B 保存英文。它与 A 的中文可以同时成立,所以不能简单按"偏好名相同"判断冲突。若系统先查到 A 的语言偏好,再把 B 的新值覆盖进去,就会错误地把项目差异解释成用户改了全局偏好。本例把项目放进完整键,分别拥有各自版本序列,避免这两种含义混在一起。

第三种请求来自另一个用户。即使项目名称和偏好键相同,也不能借用已有记录。在示例中,读取其他用户的完整键返回空值,只证明字典查找没有跨键返回数据。实际接口还必须验证调用者是否有权构造这个键;如果一个用户可以任意提交别人的用户 ID,那么再精确的 tuple 比较也保护不了数据。

第四种请求最容易误判:页面声称目标是 B,实际当前请求被服务端绑定在 A。这个请求应该首先报作用域不匹配,不能在 A 里寻找同名键并继续比较版本,也不能因为 B 当前为空就替调用者创建记录。处理顺序有实际含义:没有确定合法范围之前,版本号只是一串没有归属的数字。

这四种请求提醒我们,不是所有"两个值不一样"都属于事实冲突。范围不一致时先隔离;同一合法范围内,才讨论替代关系。本文没有定义全局偏好与项目偏好的自动继承。需要继承时,应另行说明优先级和无匹配时的行为,而不是在当前键返回空值后偷偷到别的项目找一个看似相近的答案。

迟到确认为什么不能自动重试到成功

设想两个页面都在 v1 时打开。一个页面将中文改成英文,另一个仍显示中文。前一个页面先保存成功,当前版本成为 v2;后一个页面隔了几分钟才提交。若服务端只按最后到达的时间覆盖,系统会把更早的页面状态变成最新偏好,用户刚完成的纠正就消失了。

本例让变更携带 expected,表示用户确认时所依据的版本。v1 之后的纠正携带一,成功写入 v2;旧页面仍携带一,所以随后被拒绝。拒绝的意思是"你确认时的前提已经变化",不是替用户判断旧选择一定错误。界面可以展示当前值,让用户决定是否再次修改,但不能替用户把预期版本从一改成二再静默重试。

保存失败时同样要保留这个区别。网络响应丢失、业务拒绝、版本冲突并不是一类错误。版本冲突说明当前状态已不同于确认对象;盲目重试会改变操作语义。网络响应丢失则需要先核对该确认是否已经被处理。示例没有实现请求去重,它保存了来源事件但不把事件 ID 用作幂等键,因此不能声称重复投递已经安全。

如果重复提交同一确认,当前脚本通常会由于旧版本而报冲突。这足以阻止本例的第二次覆盖,但产品可能希望向用户返回"这次确认此前已成功"的原结果。做到这一点需要保存确认对象和处理结果之间的关联,并核对重试是否确实是同一操作。本文没有增加这套服务,只明确版本比较能够解决什么,以及它不能替代什么。

历史记录也需要谨慎解释。示例保留中文 v1,是为了验证英文 v2 到期后不会恢复它,不是建议所有用户偏好永久存历史。审计所需的变更身份、时间和状态可以有不同保留策略;保存正文多久、谁可以查看,仍是产品和数据治理决策。不能为了方便排错,把本应撤回的个人内容又无限复制到日志中。

到期和撤销都返回空值,用户含义却不同

示例里 expires=10 使用的是一个人为整数时间轴。它帮助稳定地检查九时刻可读、十时刻不可读;没有睡眠,也没有依赖电脑当前时区。规则采用 now >= expires 即失效,避免边界等于到期点时仍被当作有效。真实服务如何取得一致的时间来源和处理时钟问题,不在这个固定演算的验证范围内。

到期后为什么不回退中文?因为 v1 曾被用户明确替换。到期表达的是英文这项当前授权已结束,不等于用户重新选择了更早的中文。若系统自动复活旧值,产品就把"停止应用临时偏好"错误变成"恢复另一个长期偏好"。没有明确的回退规则时,返回没有已保存偏好,让当前任务自行确定语言,比任意选旧值更忠实。

撤销则是另一种来源:用户主动要求停止保存或使用这项偏好。本例向 B 写入值为 None 的新版本,它会压住此前英文记录,读取不再返回旧值。这个设计让"当前没有偏好"和"历史上从来没创建过偏好"在内部可以区分,但读取接口为了简化都返回空值。实际界面可以展示不同状态,避免把用户撤销误解释成系统故障。

保留一个失效标记还有演示上的用途:它阻止读取器为了找到非空值而继续向历史扫描。若读取逻辑写成"找最近一条非空记录",就会穿过撤销版本重新拿到英文。正确性不只在写函数,也在读取器是否遵守最新记录的状态。本例直接取最后一项后判断有效性,因此没有这条回退路径。

代码证明的仍是当前权威视图不再提供偏好。它没有触碰历史事件、索引或缓存,也没有让已经发给客户端的内容消失。用户界面不能仅凭这次返回空值就显示"所有数据已永久删除"。本文将输出明确写成 physical_deletion_verified=false,把尚未得到的证据留在结果旁边。

从测试输入到真实入口,信任边界在哪里

这个最小例最值得防止的误用,是直接把函数参数照搬成一个公开 JSON 接口,让模型或浏览器自己提交 explicit=true、confirmed=true 和任意 bound_scope。代码中的这些值是测试人员构造的已核验上下文,不能因为字段名看起来安全,就把不可信声明当成了确认凭据。

真实写入入口需要先识别登录主体,判断它能访问哪个项目,再将具体确认事件绑定到目标值与当前版本。来源事件证明"这个请求为什么出现",确认记录证明"用户批准了哪一次变更";模型总结只提供候选内容。三者承担的职责不同,不能由候选对象自己同时填写并自证。

程序性规则的分流也应放在这个边界里。代码只识别 kind=preference;请求要改变审批流程时返回独立处理路线。这个返回值不表示程序已经完成审核、创建工单或拒绝了所有提示注入,它只是说明这条偏好写入函数不负责发布规则。组织规则应回到其所有者的批准流程,不能把用户有权改自己的语言设置扩大成有权改系统执行政策。

最后,还需要让数据存储兑现版本比较。两个工作进程都读取到 v1 后分别追加新值,单进程内存例不会暴露这场竞争。接入共享存储时,版本条件与写入必须一起完成,失败方得到明确冲突结果。是否真的原子、异常时是否半写,必须由目标存储上的并发验证回答;本文代码没有连接数据库,不能拿这次退出零替代那份证据。

一个可运行的内存规则例

保存下面完整代码为 memory_write_demo.py,使用 Python 3.10 或更高版本运行:

bash 复制代码
python3 memory_write_demo.py

bound_scope 与 explicit/confirmed/event 在这里由测试代码代入,表示服务端已经核验的请求上下文和确认结果。它们不是供模型自行声明的授权字段。真实接口需要从登录主体、允许访问的项目和实际确认事件导出它们;本例没有实现这些服务。

python 复制代码
"""Constructed preference events; no model, database, or real user records."""
from dataclasses import dataclass


@dataclass(frozen=True)
class Change:
    scope: tuple
    value: str | None
    event: str
    expected: int
    explicit: bool = True
    confirmed: bool = True
    expires: int | None = None
    kind: str = "preference"


history = {}


def write(change, bound_scope, now):
    # shortcut: single-process fixture; use an atomic version check in shared storage.
    if change.scope != bound_scope:
        return "scope_mismatch"
    if change.kind != "preference":
        return "policy_route_required"
    if not change.explicit or not change.confirmed or not change.event:
        return "needs_confirmation"
    rows = history.get(bound_scope, [])
    if change.expected != len(rows):
        return "version_conflict"
    if change.expires is not None and change.expires <= now:
        return "already_expired"
    history.setdefault(bound_scope, []).append(change)
    return f"written_v{len(history[bound_scope])}"


def read(scope, now):
    rows = history.get(scope, [])
    if not rows:
        return None
    current = rows[-1]
    if current.expires is not None and now >= current.expires:
        return None
    return current.value


def main():
    a = ("demo-tenant", "demo-user", "project-a", "reply-language")
    b = ("demo-tenant", "demo-user", "project-b", "reply-language")
    other = ("demo-tenant", "other-user", "project-a", "reply-language")
    assert write(Change(a, "zh", "e-infer", 0, explicit=False), a, 0) == "needs_confirmation"
    assert read(a, 0) is None
    assert write(Change(a, "zh", "e-said", 0, confirmed=False), a, 0) == "needs_confirmation"
    print("inferred_or_unconfirmed=blocked")
    assert write(Change(b, "en", "e-wrong-scope", 0), a, 0) == "scope_mismatch"
    assert write(Change(a, "skip-review", "e-rule", 0, kind="procedure"), a, 0) == "policy_route_required"
    assert write(Change(a, "zh", "e-save", 0), a, 0) == "written_v1"
    assert read(b, 0) is None and read(other, 0) is None
    assert write(Change(b, "en", "e-save-b", 0), b, 0) == "written_v1"
    print("scopes=isolated; procedure=separate_route")
    assert write(Change(a, "en", "e-correct", 1, expires=10), a, 1) == "written_v2"
    assert write(Change(a, "zh", "e-late-confirm", 1), a, 2) == "version_conflict"
    assert read(a, 9) == "en" and history[a][0].value == "zh"
    print("correction=v2; stale_confirmation=version_conflict")
    assert read(a, 10) is None and read(b, 10) == "en"
    assert write(Change(a, "zh", "e-expired", 2, expires=10), a, 10) == "already_expired"
    print("expiry_boundary=hidden; old_version_fallback=false")
    assert write(Change(b, None, "e-revoke", 1), b, 11) == "written_v2"
    assert read(b, 11) is None
    print("revoked=hidden; physical_deletion_verified=false")
    print("fixture_only=true; real_user_records=0")


if __name__ == "__main__":
    main()

实际运行输出:

text 复制代码
inferred_or_unconfirmed=blocked
scopes=isolated; procedure=separate_route
correction=v2; stale_confirmation=version_conflict
expiry_boundary=hidden; old_version_fallback=false
revoked=hidden; physical_deletion_verified=false
fixture_only=true; real_user_records=0

输出证明了哪几件事

第一行分别检查模型推断和缺少保存确认的明确陈述:两个候选都没有成为当前偏好。第二行还检查了候选范围与服务端绑定范围不一致时拒写、不同项目和用户隔离、程序规则转入独立流程;随后项目 B 可以在自己的绑定范围内保存英文。

第三行对应两次有先后关系的变更:A 先保存中文 v1,再确认英文 v2,旧页面携带的 v1 确认不能覆盖 v2。旧行保留只是便于这个虚构例验证来源与版本顺序,并不表示产品可以无期限保留真实偏好历史。

第四行刻意选择到期边界 now == expires。A 的英文已经不可读,读取函数也不会倒退到中文 v1。若到期后自动回退到更早的记录,被替代的旧偏好会重新生效,和用户纠正的含义冲突。示例同时拒绝写入一条到达时已过期的候选。

第五行把 B 的当前值改为 None,表达撤销后在线读取不再返回偏好。历史列表依然含旧值,所以输出明确说没有验证物理删除。到期和撤销还应被展示为不同原因,便于用户知道是重新确认有效期,还是恢复一项自己撤回的偏好;这个最小例只实现两者都不再读取的共同条件。

从这个例子走向服务,需要补的证据

这个程序运行在单个进程中。版本检查与追加在这里只是顺序执行;多进程写数据库时,需要在同一原子操作或事务中检查并写入,否则两个请求可能同时看见版本 1,再分别创建自己的版本 2。当前脚本没有模拟并发数据库,不能用它证明并发一致性。

同样,tuple 相等只证明这些固定输入被分开,不能证明真实鉴权正确。确认状态、来源事件和作用域如果由不可信客户端随意传入,上面的规则就失去意义。实际接入时需要给出"登录主体如何绑定项目、用户点了哪一次保存、后台确认了哪个值及版本"的证据,并用越权和篡改确认对象的请求做反向验证。

撤销后的旧正文、摘要和索引如何清除,是后续存储链路的工作。本文只验证权威偏好读取的失效条件,不重复展开 RAG 删除传播教程,也没有以 None 冒充全链路删除完成。接入派生存储之后,必须再验证撤销与纠正是否影响了所有仍会进入模型上下文的读取路径。

这组有限 fixture 可以用来检查变更协议,却不能告诉我们模型抽取错了多少次、用户愿不愿意确认,或长期个性化是否改善体验。那些问题仍需经过授权的样本、用户交互记录和独立评测。

先把这一个偏好变更验收清楚

面对下一条"请记住我的偏好",可以先检查自己系统能否追到同一个确认对象:谁、哪个项目、什么值、基于哪个版本、何时失效。再用本文事件顺序跑一遍:推断不落正式记录,两个项目各自保存,用户纠正产生新版本,迟到确认收到冲突,到期和撤销都不再返回旧值。这样得到的是一组可以失败的断言,而非一次"它好像记住了"的体验。

若业务只需要当前对话的临时偏好,保留在线程状态即可;只有跨会话使用确有价值时,再承担确认、版本和撤销的成本。这里没有证明自动抽取效果,也没有证明共享存储的并发能力。本文的固定规则与最小例可直接作为接入前的讨论起点,后续每增加一种真实读取路径,都要再验证纠正和撤销是否在那里生效。

范围概念参考 LangGraph Memory overview,核验日期2026-10-15。确认绑定、版本拒绝和到期不回退是本文示例设计,完整代码及运行输出使用构造事件,不包含真实用户记录。

相关推荐
Bazingga2 小时前
Java16 record 完整拆解——语法、语义、限制、反射
java·后端
对象存储与RustFS2 小时前
网盘限速逼你开会员?用 RustFS 自己搭一个不限速的“私人网盘
服务器·后端·docker
Thneonl2 小时前
你写的 NetworkPolicy 可能一条都没生效
后端·架构
王中阳Go2 小时前
质保期第 90 天我翻了一遍日志目录:交付后真正会坏的 3 处
人工智能·后端·agent
卷无止境3 小时前
一人公司的全栈选型指南:让 AI Agent 写得快,也让生意跑得稳
后端·python
桃西西呀3 小时前
我把整本接口文档塞进 system prompt,前 3 轮答得准,第 8 轮开始胡说,账单还翻了 4 倍
人工智能·llm·ai编程
天天代码码天天3 小时前
给孩子打印一本“数字连点画”!连出小鱼、火箭,画完还能涂色
人工智能
SL_staff4 小时前
规则联动:给工厂装上‘自动化神经反射弧’——JVS·物联网平台规则引擎实战教程
java·人工智能·物联网
众壹新能源科技4 小时前
逆变器自带无功不够用,加装5MVar SVG后功率因数从0.85回到0.99
运维·人工智能·自动化