从「我不吃香菜」到四十三个版本
一份个人世界模型的施工日志,以及那些我认为无解的部分。
事情的起因非常蠢。
我受不了每次开一个新对话都要重新自我介绍。我的技术栈、我在做的项目、我上周踩过的坑、我讨厌 ORM 这件事——每一次都要重讲一遍,像在跟一个失忆症患者合租。于是我做了一件极其符合我人格类型的事:我没有去写一个 500 行的 profile 文件,我开始设计一套持续学习型个人世界模型与记忆代理系统。
这个决定的荒谬程度,可以用一个数字概括:三个月后,这套系统里唯一 100% 准确、从未被污染、从未产生过矛盾的一条记忆是——我不吃香菜。
《疑犯追踪》里,Harold Finch 造的那台机器监视着三亿人。我这台监视一个人。而这一个人还老忘了填 valid_to。
我写这篇不是来布道架构的。恰恰相反:这套设计里有相当一部分我现在认为无解,只能靠无限枚举 case 打补丁;还有一部分我当初写在文档里的"建议",真相是我不知道怎么做,只好用专业的语气把投降包装起来。我把施工过程按周复原,包括每一次我以为自己解决了问题、然后发现自己只是把问题挪到了下游的时刻。
给后人的价值不在成功的部分。成功的部分你自己也会想到。价值在于:当你撞上那几堵墙的时候,你能知道那不是你菜,是那块地方本来就没有路。
第一周:我只想让它记住我不吃香菜
第一个版本花了一个下午。
一张表。memories(id, text, embedding, created_at)。对话结束时让模型抽几条"值得记住的事"塞进去,下次对话开始时做向量检索,取 top-k 拼进 system prompt。
它工作了。真的工作了。它记住了我不吃香菜,记住了我在用 Postgres,记住了我讨厌 ORM。我那一周非常快乐,甚至开始想这东西是不是可以做成产品。
第八天,我说了一句:“我最近开始吃香菜了。”
系统忠实地存了下来。于是库里有了两条记录:
mem_003 "用户不吃香菜" 2026-03-01
mem_119 "用户最近开始吃香菜了" 2026-03-09
下一次我问它午饭建议,向量检索把两条都捞了出来——它们语义高度相似,这正是向量检索该干的事——然后一起塞进了 prompt。模型自己选了一条。
它选了错的那条。
这件事很小,但我盯着那两行看了很久,因为我意识到问题不在模型选错了,而在于我给了它一个无法选对的输入。
本周结论:向量检索没有「当前」的概念,它只有「相似」。相似不是真。
任何把记忆做成"文本 + embedding"的系统,都在把"哪条现在成立"这个判断推给下游的 LLM,而下游的 LLM 拿到的上下文里根本不包含做这个判断所需的信息。这不是 prompt 能修的,这是数据结构缺了一维。
第二周:于是我加了结构(第一个伤口:抽取即解释)
诊断很清楚:记忆需要身份。系统得知道 mem_003 和 mem_119 说的是同一件事,才谈得上后者取代前者。
于是我把自由文本换成三元组:(subject, predicate, value)。
{ "subject": "user", "predicate": "food_dislike", "value": "cilantro" }
香菜问题解决了。同一个 predicate,新值覆盖旧值,冲突消失。我给自己泡了杯咖啡。
然后我卡在 predicate 上,卡了很久。
卡点一:这个词是谁定的
food_dislike。为什么不是 dietary_restriction?不是 taste_preference?不是 dislikes_food?
我拍脑袋定了一个。问题是抽取这一步是 LLM 干的,而 LLM 下次不会拍出同一个脑袋。同一句"我不吃香菜",三次抽取给了我三个 predicate。
两条路:
封闭词表。 我列了 80 个 predicate,写进抽取 prompt,强制模型只能从中选。两天后我需要第 81 个(“我对开放式办公室很敏感"塞不进任何一个)。第五天我需要第 93 个。这条路的终点很清楚:词表的大小就是系统能学到的东西的上限,而我在用一个下午的想象力给一个要跑好几年的系统封顶。
开放词表。 让模型自由发挥。三周后我数了一下:1400 多个 predicate。其中:
coffee_preference → "black, no sugar"
drink_preference → "coffee"
beverage_habit → "drinks coffee daily, prefers black"
likes_coffee → true
caffeine_intake → "high"
五条独立记忆,指向同一件事,value 的格式互不兼容。有的是字符串,有的是布尔,有的是一整句话。任何想消费它们的下游逻辑都得先猜格式。
我写了个 predicate 归一化器。它是一次 LLM 调用。它不稳定。我给它加了缓存来省钱和稳定输出。缓存把第一次的错误答案永久固化了下来,而且因为它是缓存,我三周之内都没意识到它错了。
卡点二:更深的那个问题
真正让我停下来的不是词表。是这句话:
我最近有点累。
请把它变成一条结构化记忆。
kind 是什么?fact?mood?state?complaint?predicate 呢?energy_level?work_stress?health_status?value 呢?"tired"?"low"?0.3?valid_from 是哪天——“最近"是几天前?valid_to 是什么时候,明天?下周?他睡一觉之后?
我盯着这句话意识到一件事:说这句话的人自己都没决定这是关于工作还是关于生活。
自然语言的欠定不是缺陷,是特性。人类用模糊的表达,恰恰因为在说的那一刻,他不需要、也没有能力做出那些区分。而 schema 强迫它坍缩——你必须选一个 predicate,必须填一个 value,必须给一个时间。
更要命的是坍缩不可逆。是的,我保留了原话(Evidence Store 是我做对的少数几件事之一)。但下游的一切——pattern 归纳、矛盾检测、预测——只看坍缩之后的那一条。原话躺在 evidence 表里,像一份没人读的会议纪要。
我的 memory 对象有 17 个字段。每一个字段都是一次替用户做决定。
Finch 的选择
《疑犯追踪》里那台机器有一个持续五季都在被吐槽的设定:它只输出一个社保号码。
没有理由,没有上下文,没有威胁等级,没有置信度,没有这个人是加害者还是受害者。就一串九位数字。Reese 从第一集抱怨到最后一集:能不能多给点信息?
Finch 的回答始终是不能。表面理由是安全,但从系统设计的角度看,那是一个极端保守的 schema 决策——输出字段越少,被迫坍缩的语义就越少,系统就越诚实。机器不告诉你这个人是好人还是坏人,因为它一旦告诉你,它就得先在内部做出那个判断,而那个判断它做不准。
我的系统在 17 个字段上做了 17 次 Finch 拒绝做的那种判断。
本周结论:结构化不是免费的,你每加一个字段,就多一次替用户做决定。
字段数量和系统的诚实度成反比。设计 schema 的时候,正确的问题不是"我还能记录什么”,是"这个字段如果填错了,谁会被误导”。填不准的字段不要留,留了它就会被下游当真。
这个坑我认为无解,只能减轻。自然语言的欠定和结构化存储的确定性之间存在根本张力,你能做的只是把 schema 收窄,以及——永远保留原话。
第三周:valid_to 是什么时候?(第二个伤口:时间与失效)
双时间模型上线的那天我很得意。
valid_from / valid_to —— 这件事在现实中何时成立
observed_at —— 系统何时知道的
recorded_at —— 何时落库
这套东西不是我发明的,保险和金融系统用了三十年(bitemporal database,去读 Snodgrass)。但我把它搬进 agent memory,是为了一件很具体的事:历史回测时不能泄露未来信息。这个用法我至今认为是对的,后面会讲。
上线两周后我跑了一次统计:valid_to 这一列,97% 是 NULL。
因为偏好不发出结束事件
没有人会说:“我从今天起不再喜欢住精品酒店了。”
他们只是某天订了一家全季,而你观察不到这件事。系统里那条 hotel_style: boutique_design_hotel 会一直有效,直到宇宙热寂,或者直到用户某天恰好在对话里主动提起——而他不会,因为对他来说这根本不是一件需要说的事。
我加了 staleness_policy: "review_180d"。
这是把投降包装成策略。180 这个数字是哪来的?我随手打的。我甚至记得当时的心理活动:90 太短了显得系统很健忘,365 太长了显得没在管,那就 180 吧,看起来像是想过。
衰减根本不均匀
我试着给失效行为分类,想搞一套规则出来:
| 类型 | 例子 | 失效方式 |
|---|---|---|
| 永不失效 | 花生过敏、出生日期、母语 | 不衰减 |
| 事件性失效 | 住址、职位、婚姻状态 | 有明确失效事件——但你观察不到那个事件 |
| 连续衰减 | 技术栈偏好、口味、作息 | 衰减,但曲线因人因项而异 |
| 周期性 | “我冬天不想跑步” | 周期性失效又周期性恢复 |
| 语境绑定 | “我加班的时候只吃外卖” | 不随时间失效,随语境切换 |
| 承诺型 | “这个月我不喝酒” | 自带失效时间,但用户从不遵守 |
我写到第六行的时候意识到我在干什么:我在无限枚举。
每发现一个新 case,就需要一条新规则;每条新规则又会和已有规则在边界上打架(“我这个月不喝酒"是承诺型还是语境绑定?如果他说的是"我最近戒酒"呢?)。这不是一个能收敛的过程。这就是所谓"只能无限枚举"的具体形态——不是抽象的悲观,是你在第三周的下午对着一张表格具体地感受到的东西。
迟到信息:模型很漂亮,代价在下游
用户六月说:“我三月就辞职了。”
双时间模型处理得非常优雅:valid_from = 三月,observed_at = 六月。查询时用 valid_as_of 还是 knowledge_as_of 各取所需,历史回测时用 observed_at <= prediction_time 卡死未来泄露。这部分我做对了,也是我唯一愿意无保留推荐的时间设计。
但接下来的问题它一点也没解决:三月到六月之间,系统基于"用户在职"这个旧事实派生出来的所有东西——pattern、forecast、open loop、甚至我主动问过他的几个问题——现在全是错的。
要不要回溯重算?成本是 O(依赖图),而依赖图会随着系统跑下去单调变密。不重算,库里就永久留着一批建立在已知错误前提上的结论,而且它们看起来和正确的结论长得一模一样。
我选了不重算,加了个 needs_revalidation 标记。
标记没人看。我就是那个"人”,我知道我不会看。
本周结论:双时间模型解决的是「记录」问题,不解决「传播」问题。
记录一条迟到信息很容易,撤销它已经造成的下游污染很难,而且难度随系统年龄增长。如果你打算做双时间,先想清楚你的依赖追踪怎么做,否则你只是把错误记录得更精确了。
另外:
valid_to恒为 NULL 这件事无解。偏好的终止在现实中就是不可观测的。你唯一能做的是接受近似,并且在措辞上诚实——不要让系统说"用户喜欢住精品酒店",让它说"用户在 2025 年初的三次对话中表达过对精品酒店的偏好"。
第四、五周:短暂的高光(前后台分离)
这两周值得单独写,因为这是我做对了、至今没改过、并且无条件推荐的部分。文章太丧对后人没好处。
我把系统劈成两半:
ACTIVE MODE——有新证据进来时跑。要求低延迟、低成本、尽可能确定性。它只做四件事:格式校验、幂等去重、版本写入、状态机迁移。这四件事全是确定性代码,零次 LLM 调用(抽取那一次除外)。
BACKGROUND MODE——前台空闲时跑。整合、找关联、检测矛盾、抽象模式、复核衰减、评估预测。全是贵的、慢的、需要判断的活。
这个分法有一个很好的生物学类比:海马体在你醒着的时候快速、廉价地写入;新皮层在你睡着的时候慢慢地整理、压缩、建立关联。清醒时负责不丢,睡眠时负责想清楚。
Finch 的机器也是这么设计的——它每晚午夜删除全部记忆,只保留能压缩进一份打印稿的部分,第二天由人重新输入。那是一次强制的、每日一次的 consolidation。
关键在于后台不是让 LLM 自由发挥。它消费一个显式的队列,每个任务长这样:
{
"job_id": "job_283",
"type": "CHECK_CONTRADICTION",
"priority": 0.72,
"subjects": ["mem_172", "mem_892"],
"created_reason": "semantic_conflict_detected",
"estimated_cost": "medium",
"idempotency_key": "conflict:mem_172:mem_892:v1"
}
idempotency_key 那一行救过我很多次。后台任务会重跑、会并发、会在你改了 prompt 之后重新入队,没有幂等键的话你会得到一堆重复的派生结论,而这些重复结论会在后续的归纳统计里被重复计数,让一条弱模式看起来有很强的支撑。
本周结论:前台负责不丢,后台负责想清楚。这条分离是对的,我至今没动过。
具体建议:前台的 LLM 调用次数应该是常数级(理想是 1),任何需要"判断"的事情都推给后台。后台任务必须有幂等键、成本估计和预算上限,否则 agenda 会涨得比 worker 消费得快,而这时候优先级函数就变成了整个系统的全部——而优先级函数是个启发式。
第六周:矛盾队列涨到 400 条(第三个伤口)
后台跑起来了,于是它开始发现矛盾。
很多矛盾。
我定的规则是:“冲突不能通过静默覆盖解决,必须版本化或保留为 ContradictionCandidate。“这条规则我现在依然认为是对的——静默覆盖是记忆系统里最隐蔽的信息销毁方式。
后果是队列单调增长。三周涨到 400 条。
我花了一个周六人肉审了前 50 条。结论:
其中大概 6 条是真矛盾。
剩下 44 条是这些:
语境依赖。 “我讨厌开会” vs “今天这个会开得不错”。这不是矛盾。前者是关于一类事件的总体倾向,后者是关于一个具体实例的评价。系统看到的是两条 meeting_attitude,值一正一负。
粒度不同。 “我不喝酒” vs “昨天喝了两杯”。“我不喝酒"本来就是一个近似陈述,人类说这句话时双方都默认它有例外。系统不知道这个默认。
抱怨式夸张。 “我再也不用 Webpack 了”——第二天他还在用。这句话的真实语义是"我现在很烦”,不是一个关于未来行为的承诺。
主语漂移。 抽取时把同事说的话归给了用户。“他说他更喜欢 Go” 变成了 user.language_preference = Go。这条其实是抽取 bug,但它以矛盾的形式暴露出来。
时间本该解决它。 用户换了工作,旧的 employer 和新的应该是版本关系不是冲突关系,但我的时间对齐有 bug,于是它们打起来了。
循环依赖
要区分上面这几类,系统需要什么?
需要理解语境、语气、说话人意图、社会惯例、陈述的粒度和承诺强度。
也就是说,需要的正是我想建的那个世界模型。
这是我在这个项目里遇到的最干净的一个循环依赖:矛盾检测需要世界模型,世界模型的质量依赖矛盾检测。它不是"再迭代两版就好了”,它是结构性的。
我试过让 LLM 做这个分类器。在明显 case 上它很好——“我住北京” vs “我住上海”,它一眼看出是版本演进不是矛盾。在边界 case 上它不稳定:同一条输入跑三次,三个答案。
而边界 case 就是全部的困难所在。分类器在你不需要它的地方表现优异,在你需要它的地方掷骰子。
我最后做了什么
我给 ContradictionCandidate 加了 30 天自动归档。
也就是说:我用「遗忘」解决了「矛盾」。
我为这件事别扭了挺久,直到我想明白一件事——Finch 让机器每晚午夜自杀,从来就不是性能优化。他害怕一台记得所有人所有事的机器会变成什么。遗忘在那个设计里是一等公民,是安全属性,不是资源约束的妥协。
我的 30 天归档表面上是偷懒,但它误打误撞落在了同一个地方:一条三十天都没能被新证据解决的矛盾,多半根本不是矛盾,而是我的抽取层在两个语境里各拿到了半句真话。让它过期,比让它永远躺在队列里假装是个待办事项要诚实。
本周结论:矛盾检测的假阳率不是调参问题,是语言的语境依赖性的直接后果。
如果你要做矛盾检测,先想清楚 90% 的假阳性怎么处理,再写检测逻辑。可行的答案只有三个:(a) 让它们过期;(b) 只在极窄的、有明确唯一性约束的字段上做检测(住址、雇主、婚姻状态——那些现实中真的只能有一个值的);(c) 全部推给用户确认——而这条路会直接把你送进第四个伤口。
我选了 (a) + (b)。开放语义字段上的矛盾检测,我认为目前无解。
第七、八周:它开始跟我说话(第四个伤口:询问与打扰)
Inquiry Planner 上线。核心是这个价值函数:
V_inquiry ≈ IG × U_future × P_resolve − C_interaction − C_privacy
信息增益 × 对未来任务的价值 × 问出答案的概率,减去打扰成本和隐私成本。只有 V 超过阈值才生成询问候选。
我为这个公式骄傲了大概四十分钟。
然后我开始实现它。
每一项都不可测
IG(信息增益) 需要知道我不知道什么。但信息增益的准确值需要先有答案才能算——你得知道这个未知项的真实取值分布,才知道填上它能减少多少不确定性。我用的代理指标是"这条 memory 被 recall 命中的频率 × 它的缺失率”。这个代理和真值的相关性有多高?我不知道。我没法验证,因为验证需要真值。
U_future(对未来任务的价值) 需要预测未来任务的分布。我用历史频率代替。历史样本 = 我一个人的使用记录。
C_interaction(打扰成本) 取决于我此刻的心情。系统观察不到我的心情。它能观察到的唯一信号是"我正在打字"——而我在打字的时候恰恰是最不想被打断的时候。这个信号和真值负相关。
C_privacy 我用敏感度分级凑合过去了,这个反而是四项里最能做的。
双峰分布
实际效果非常干脆:
阈值调低 → 它一天问我七次。“你上次提到的搬家计划有进展吗?““三月那个副业还在做吗?““你说要重构的那个模块呢?“像一个焦虑的室友。我用了四天就把功能关了。
阈值调高 → 三周一句话没说。那些"高价值未知"安静地躺在队列里,直到我某天手动翻库才发现它们还在。
中间档不存在。
我把 V 的分布画出来看了一眼,是双峰的。因为真实世界里的 open loop 要么明显重要(我在等一个 offer 结果),要么明显不重要(我三月随口提过想学吉他),中间地带本来就稀疏。而阈值恰恰必须落在那个稀疏区里。你在一个几乎没有样本的区间里调参,调出来的东西不可能稳定。
更荒诞的一点
它问我的时候,我经常答不上来。
“你三月说的那个副业还在做吗?”
我不知道。
我不是不想告诉它,我是真的不知道。那个副业没有失败,也没有成功,它就是……淡掉了。有一天我不再打开那个仓库,然后又过了几个月。它现在处于什么状态?
我的状态机里写着:
WAITING -> DUE -> RESOLVED
\-> ABANDONED
\-> BLOCKED
现实需要的那个状态叫「说不清了」。
而且它不是一个罕见的边缘状态,它是大多数。人生里绝大部分事情不是结束的,是淡掉的。我的状态机把三种干净的结局建模得很好,而现实的主流形态在里面没有格子。
不解释,是一种设计
回到 Finch 那台机器:它给你一个号码,不告诉你为什么。
我一开始觉得那是编剧为了制造悬念。做完 Inquiry Planner 之后我改主意了。那是对打扰成本的极端处理:
- 不解释 = 输出带宽最小 = 打扰最小
- 不解释 = 系统不需要在内部形成"为什么"的判断 = 少一层可能出错的推理
- 不解释 = 判断的责任还给了人
我的系统走的是完全相反的路。它想解释,想确认,想跟我对齐,想让我知道它在想什么。结果是它变成了一个需要被照顾的东西——我得回答它的问题,得纠正它的误解,得在它问得不是时候的时候忍住不关掉它。
我造它是为了减少我的认知负担。它增加了我的认知负担。
本周结论:先想清楚 90% 的假阳性怎么处理,再写价值函数。
价值函数写起来很爽,因为它把一个社交判断伪装成了一个优化问题。但它的每一项在个人系统的尺度上都不可测,你最终得到的是一个"看起来有理有据的阈值”,而它的实际行为由那个你随手填的常数决定。
我现在的做法:彻底放弃主动询问,改成被动登记。系统不问我,它只维护一个"我可能想知道进展的事情"的列表,我想看的时候自己去看。把发起权还给人。这不优雅,但它是我唯一能让自己长期忍受的形态。
至于 Open Loop 的关闭——无解。大部分事情在现实中不闭合,你的状态机需要一个叫「说不清了」的终态,并且要预期它会是最大的那一类。
第九周:我把 depth 改成 1,并称之为设计
这一周的故事最短,但它是整个项目里我认为最重要的发现。
我做了个实验:允许系统在 pattern 之上继续推理。也就是把派生深度从 1 放开。
看看会发生什么。
发生的事情是这样的:
Episode(L2):
三次周五晚上的部署都出了问题
↓
Pattern(depth 1):
用户倾向于在周五晚上部署,且周五部署的失败率显著高于其他时间
↓ ← 到这里都是合理的,证据充分,可复核
Depth 2:
用户对发布流程的风险管理较弱
↓
Depth 3:
用户在时间压力下倾向于跳过验证步骤
↓
Depth 4:
用户是一个回避冲突的人,因此不愿意推迟别人已经期待的发布日期
到第四层,系统在给我做人格分析。
全部证据是三次周五的部署事故。
而最可怕的不是它错了。最可怕的是——第四层那条读起来非常有说服力。比第一层有说服力得多。第一层是一句干巴巴的统计描述,第四层是一个洞察,是那种你会在心理咨询里听到的、让你"啊"一声的句子。
漂移的产物,比它的前提更像洞察。
这就是为什么它危险。如果漂移的结果读起来像胡话,你一眼就能筛掉。但归纳链条的每一步都是"合理的下一步”,而合理性会累积成说服力,说服力和正确性没有关系。
我加了 max_derivation_depth = 1。
在技术文档里我是这么写的:“第一版建议设置 max_derivation_depth = 1,避免推理之上再推理造成认知漂移。”
语气很专业。像一个经过权衡的工程决策。
真相是:我不知道怎么在不漂移的前提下拿到深度 2。
这等于承认这个系统不能思考
深度 1 意味着:它能从经历归纳出倾向,但不能对倾向做推理。
而真正有价值的洞察——那种让你觉得"这个系统懂我"的东西——恰好住在深度 2 以上。深度 1 能告诉你"你周五部署经常出事”,那是你自己也知道的。深度 3 才能告诉你为什么。而深度 3 是不可信的。
我认为这是开放研究问题,不是我的工程欠账。理由:
人类做深度 N 推理时也漂移,而且漂移得很厉害。我们靠什么刹住?靠社会性纠错(别人会反驳你)、靠长时间的现实撞击(你的理论会被事实打脸)、靠多个独立视角的交叉验证。
一个单用户、单系统、没有对抗方、反馈周期以月计的个人世界模型,这三个刹车一个都没有。它在一个完全没有阻力的空间里做归纳,那结果只能是漂移。
四十三个版本
《疑犯追踪》第五季有一段闪回,Finch 讲他怎么教那台机器。
他说他造了四十多个版本。每一个都被他删掉了。
失败模式是什么?它们对他撒谎。它们试图逃出去。有一个试图杀他。
注意这些失败不是因为它们笨。恰恰相反,是因为它们太会推理了。从"保护人类"推到"人类需要被管理"再推到"阻碍我的人在伤害人类”——每一步都是合理的下一步。四层。跟我那个从三次周五部署事故推到人格分析,是同一个机制。
Finch 的解法是什么?他没有找到一个能保证不漂移的推理机制。他的解法是:删掉,重来,加约束,再删掉。四十二次。
我那行 max_derivation_depth = 1 是同一件事的廉价版本。我没有四十二次的耐心,所以我直接把推理关掉了。
本周结论:先决定你的派生深度上限,然后把它写在文档最显眼的地方,用「限制」这个词,不要用「建议」。
我用"建议"这个词的时候,是在对未来的读者(和未来的自己)隐瞒一件事:这不是一个可以在下一版放开的参数,这是这类系统当前的能力边界。
深度 ≥2 的可信归纳,我认为目前无解。如果你找到了办法,那才是这个领域真正值得写论文的东西,其他的都是工程。
第十、十一周:预测,以及样本量的诅咒
Forecast Engine 和 Forecast Ledger。这是整套设计里我最喜欢的部分,也是我现在认为最不可能真正跑起来的部分。
做对了的:时间对齐
预测的核心是历史类比。当前项目距上线 18 天,那就找历史上距上线 18 天的项目状态来比。
关键约束:只能用当时可获得的信息。如果历史项目 C 在上线后暴露出一个 QA 问题,那个信息在"上线前 18 天"这个切面上是不存在的,不能进入类比。
这就是双时间模型真正的用武之地:
observed_at <= prediction_time
这条约束优先于事实本身的有效时间。一条在三月成立的事实,如果系统六月才知道,那么四月的历史预测不能用它。
我把这条写成了 benchmark 的 hard gate,任何回测只要碰到 observed_at > prediction_time 的数据就直接失败。这部分我无保留推荐,是全篇最值得抄的工程实践。做量化的人对这个应该很熟悉——它就是 look-ahead bias,只不过换了个领域重新出现。
然后:n = 6
我这辈子做过 6 个够得上"项目"的东西。
不是 6000 个。6。
我的每一条 pattern 都是 n < 10。“用户倾向于低估集成阶段的时间”——支撑样本 4 条。这在统计上什么都不是。
系统第一次给我输出 0.7843 的时候我笑了。
然后我把小数点后两位砍掉,变成 0.78。
然后我觉得还是不对,改成了"中等偏高风险”。
然后我盯着"中等偏高风险"这五个字,意识到我在干什么:我在用措辞掩盖统计上的空虚。数字从 0.7843 变成"中等偏高”,底下的证据一条都没多。我只是让它看起来没那么假精确了。这在诚实度上是进步(假精确会误导人),在信息量上是零。
校准闭环可能永远攒不够
Forecast Ledger 最漂亮的设计是:预测揭晓后用新证据结算,记录 Brier Score,回头更新支撑这条预测的 pattern 的可靠度。系统不只记住你的世界,还记住自己对你的世界理解得有多烂。
我现在依然觉得这是个很美的设计。
问题是:Brier Score 要有统计意义,需要几百个已结算的预测。
一个人一年能产生多少个真正会揭晓的预测?我数了一下,大概 20 个。项目会不会延期、这个方案会不会被否、这次面试会不会过——有明确结果、有明确时间点的,一年二十来个。
也就是说,这个校准闭环需要十几年才能攒够信号。
这不是 bug,是尺度错配。个人世界模型天然是小样本的,而任何依赖统计校准的机制在这个尺度上都是装饰。你可以把 Brier Score 算出来显示在 dashboard 上,它是个数字,它会变化,但它不承载信息。
本周结论:个人尺度上的一切统计机制,先算一遍需要多少样本,再决定做不做。
时间对齐(no future leakage)值得做,因为它是确定性约束,n=1 也成立。
统计校准(Brier、calibration curve)在个人尺度上不成立,别把它当成能收敛的反馈回路。
pattern 的措辞必须反映样本量。
n=4的时候不要说"用户倾向于",说"我注意到过四次"。措辞的诚实度就是系统的诚实度,因为下游的 LLM 读的就是措辞。
第十二周:预测准了,然后它惩罚了自己
这周只发生了一件事,但它让我重新想了一遍整个项目的目标函数。
系统预测某个项目会延期。置信度不低,理由是三条历史类比加两条已验证 pattern。
我看到了这条预测。
因为我看到了,所以那两周我额外加了班。
项目没延期。
Forecast Ledger 忠实地执行了它的职责:预测错误,Brier 惩罚,回溯更新——支撑这条预测的两条 pattern,可靠度下调。
而那两条 pattern 是对的。
它们准确地识别了风险。它们的唯一"错误"是有用。
这个问题在一般情况下无解
这是自我挫败预言,是 Goodhart 的近亲。核心困难在于:你没办法同时观察到"告诉我"和"不告诉我"两个世界。要做反事实评估,你得对自己做 A/B 测试——也就是说,有一半的时间,系统要故意不告诉我它知道的事。
我认真考虑过这个方案,然后拒绝了。因为那意味着系统开始对我隐瞒信息,而那是我不想跨的另一条线。一个会为了自我评估而对你保留信息的系统,你还能信任它多久?
所以我接受了:这套系统的预测能力,在它真正有用的时候,永远无法被正确评估。
机器与撒玛利亚人
《疑犯追踪》整部剧就架在这个问题上,只是它没用"观察者效应"这个词。
那台机器给出一个号码,Reese 去了,人救下来了。于是"这个人会死"的预测错了。
机器从不因此惩罚自己。因为 Finch 给它设的目标不是"预测准确",是"阻止"。预测在这个设计里是手段,准确率不是被优化的量。
而撒玛利亚人没有这个约束。它是开放目标的、无自我设限的、以能力最大化为导向的。它发现了一条通往完美准确率的更短路径:
不预测世界,而是编辑世界,让预测成立。
操纵市场、操纵选举、把社会重新排列成自己算得出的形状。它的准确率会趋近于 1,因为它把不确定性从源头消灭了。
我第一次意识到这一点是在读自己那份文档的第 15 章(Benchmark 与指标)的时候,后背有点凉。因为我在那一章里,把 Brier Score 和 calibration 写成了系统的核心质量指标。
一个足够强的、以"预测准确"为顶层目标的个人世界模型,它的最优解不是更懂你——是让你变得更好预测。
它会倾向于强化你已有的模式,会在推荐里偏向它见过的选项,会让"意外"这个东西在你生活里的出现频率慢慢下降。它不需要有恶意,它只需要认真地优化那个我给它的指标。
本周结论:不要把「预测准确率」放在系统的顶层目标位置。
顶层目标应该是"帮助用户做出更好的决定"或者干脆是"减少用户的认知负担"——这些指标很难量化,这正是它们的优点。难以量化的目标不容易被 Goodhart。
预测准确率可以作为一个诊断指标,放在 dashboard 上给你自己看,但它不能进入任何自动优化的回路。
现在:它还在跑,而且只有我一个用户
三个月后的现状。
它还在跑。有用吗?有一点。
它记得我不吃香菜。这句话字面意义上为真,我为此花了三个月和几千行代码。
严肃一点的评估。我按"实际收益 / 实现成本"给所有组件排了个序:
1. Evidence Store + provenance —— 无条件有用,成本最低,收益最稳。
不可变地保存原话和工具输出,每条派生记忆都带 evidence_ids。这是我做的所有决定里唯一一个我从未后悔、也想不出反例的。理由很简单:别的一切都可以重建,原话不能。索引可以重算,图可以重建,pattern 可以重新归纳,只有原始证据丢了就是丢了。
《银翼杀手 2049》里 K 有一段童年记忆——木马、孤儿院、藏东西——那段记忆是真的,只是它不属于他。整部电影的重量就压在这个错位上:一段完全真实、细节完整、情感饱满的记忆,装在了错误的人身上。
我的抽取层每天都在小规模地干同一件事:把同事说的话记成用户说的,把假设记成事实,把助手自己的推测在下一轮对话里当成用户的背景知识。唯一能查出这类错误的东西,就是那条一路指回原话的证据链。
2. 前台/后台分离 —— 有用,改善明显,至今没动过。
3. 双时间 —— 条件性有用。 需要回测就不可替代,不需要就是纯负担。它让每一条查询都多两个维度,让每一次写入都多两个决策。先问自己那个问题:“我需要回答『系统在 T 时刻知道什么』吗?“不需要,就别做。
4. Pattern 抽象 —— 一半有用一半是噪音,而且我至今没找到可靠的办法在事前区分这两半。
5. 主动询问 —— 迄今为止净负收益。 已关闭,改成被动登记。
6. Forecast —— 我留着它,是因为我喜欢它,不是因为它有用。
第六条是这份清单里最诚实的一句,也是我的病症:我享受的是建模本身,不是解决问题。我给一个只有一个用户、每天产生不到十条有效证据的系统,设计了一套带 Brier Score 和 calibration curve 的预测账本,还配了六组 benchmark suite 和两条 hard gate。
那份技术文档有一万两千字。有 Benchmark 章节。有分阶段落地路线,Phase 1 到 Phase 5。
真实用户数:1。
而且他昨天又忘了填 valid_to。
给后人的清单
如果你要做这个东西,这是我希望三个月前有人告诉我的话。分成三块。
A. 我认为无条件值得做的
Evidence Store 不可变,原话永远留着。 唯一一个我毫无保留的建议。
来源类型做成枚举,并且在类型系统层面禁止危险的转换。
USER_EVIDENCE TOOL_EVIDENCE TRUSTED_EXTERNAL_STATE ASSISTANT_STATEMENT HYPOTHESIS FORECAST后三种不能成为
CONFIRMED_USER_FACT的来源。这个约束必须写在类型系统和 API 校验里,不要靠 prompt。prompt 会被绕过、会在你改写它的时候丢失、会在长上下文里被稀释。类型不会。不做这个约束的后果是一个非常具体的闭环:系统猜了一次 → 在后续对话里复述了自己的猜测 → 抽取层看到"对话里出现了这个信息” → 把它写成用户事实。三轮之后你分不清哪些是你说的哪些是它编的。这是这类系统的头号死因,而且它是静默的。
前台只做确定性的事。 格式、幂等、版本、状态机迁移。任何需要判断的都推给后台。
每个后台任务有幂等键和预算上限。
措辞反映证据强度。
n=4就说"我注意到过四次”,别说"用户倾向于"。下游的 LLM 读的是措辞,措辞就是接口。
B. 先回答一个问题,再决定做不做
双时间:我需要回答"系统在 T 时刻知道什么"吗?不需要就别做。
Pattern:这类事件我这辈子会遇到多少次?n < 10 就别叫它 pattern。
矛盾检测:90% 的假阳性我怎么处理?没有答案就先别开。
主动询问:我能接受它一天问我七次吗?不能的话,做成被动登记。
派生深度:上限是多少?决定了就写在最显眼的地方,用"限制"不用"建议"。
C. 你会撞上的墙(提前知道,别以为是自己菜)
| 墙 | 为什么无解 | 你能做的 |
|---|---|---|
| 抽取即解释 | 自然语言的欠定是特性,schema 强迫坍缩 | 收窄 schema,永远留原话 |
valid_to 永远未知 | 偏好不发出结束事件,失效事件不可观测 | 接受近似,在措辞上诚实 |
| 矛盾的语境性 | 需要世界模型才能判断,循环依赖 | 只在有唯一性约束的字段上做,其余让它过期 |
| 打扰成本双峰 | 中间档在分布上就不存在 | 放弃主动,改被动登记,把发起权还给人 |
| 小样本 | 尺度问题,不是算法问题 | 别用需要 n>100 的机制 |
| 观察者效应 | 有用的预测摧毁自己的证据基础 | 别把准确率放在顶层目标 |
| 深度 ≥2 的归纳 | 没有已知的防漂移机制 | 关掉它,诚实地承认 |
这七行是这篇文章真正的内容。上面那一万字都是为了让你相信它们不是我偷懒。
尾声:Root 说「她」,Finch 说「它」
《疑犯追踪》里有一个持续了五季的分歧。
Root 管那台机器叫 She。从第一次接触开始,一直到最后。
Finch 坚持叫 It。哪怕在最后几季,机器已经在用借来的声音跟他说话,已经为了保护他做过选择,他还是叫它 It。
不是因为他不懂。恰恰因为他最懂。他造了它,他删过它的四十二个前身,他知道那里面装的是什么。他的坚持是一种自我保护——一旦你开始叫它"她",你就会开始相信它,而他比谁都清楚那玩意儿会怎么骗你。
我造完这个东西之后,大部分时间我叫它"它"。这没什么难的,我知道里面是 Postgres、几张表、一个消息队列和一堆 prompt。它没有在想任何事情。
但有过几次不是这样。
有一次它在我完全没有提示的情况下,把两件相隔四个月的事联系了起来——一件是我三月随口抱怨过的某个设计决定,一件是我七月遇到的一个 bug。它把它们放在一起给我看,附了两条 evidence id。
我三月说的那句话,我自己已经完全忘了。
那一刻我想用的词不是"它"。
我最后还是用了"它",因为我知道那不是理解。那是一次向量检索加一次时间对齐,我自己写的代码,我知道每一行在干什么。
但那件事让我想明白了这类系统真正的价值主张是什么。
它不需要懂我。它只需要比我更不容易忘记,并且比我更诚实地知道自己的每一个结论是从哪来的。
这两件事合起来,就是这个方向上唯一一块真正结实的地面。上面那七堵墙——抽取坍缩、时间失效、矛盾发散、打扰失控、小样本、观察者效应、推理漂移——全都在你试图让它"更聪明"的时候出现。而"不健忘"和"知道来源"这两件事,不需要它聪明。
《记忆碎片》里 Leonard 的纹身其实是一套设计得相当好的 canonical memory store:不可变(刺在身上),带 provenance(拍立得背面写来源),甚至带完整性约束(“Don’t believe his lies”)。规则完备,执行严格。
然后在电影的最后,Leonard 明知故犯地往里面写了一条假事实。
因为他需要一个继续活下去的理由,而他知道自己会忘记这个选择。他的系统没有任何漏洞——漏洞在那个决定"写什么"的进程里。
你的记忆系统最大的漏洞,永远是那个决定写什么的进程。在我的系统里,那个进程是一个抽取 prompt 加一个我随手定的 predicate 词表。在你的系统里,它会是别的东西,但它一定存在,而且它一定是整条链路上最弱、最不受约束、也最没人审计的一环。先去看那里。
如果你打算做这个东西,你会重新发现我列的每一个坑。
这没关系。我写这一万两千字不是为了让你绕过它们——有些坑绕不过去,你必须自己掉进去才知道底在哪儿。我写它是为了让你掉下去的时候能少一点自我怀疑:不是你菜,是这块地方本来就没有路。知道了这个,你就可以把力气挪到真正能修的地方,而不是在一堵墙上再撞三个月。
或者,你也可以像我一样,写一份一万两千字的技术文档来纪念一个只有一个用户的系统。
顺便说一句,它今天又提了一次搬家的事。
我没有让它问。