数据截至 (上游 commit 358c16bbc6d6)
核心:三次 LLM 调用的抽取-合并管线
30 秒导读: 缓冲区攒够一批聊天后会触发一次 "flush"。这一章讲的就是这次 flush 里发生了什么: 聊天记录先被摘成一份结构化 memo,再从 memo 里抽出一条条画像事实,和用户已有画像做智能合并 (更新 / 追加 / 丢弃),同时给这批聊天打事件标签,最后一起落库。整个过程从 0.0.40 版起被固定为 3 次 LLM 调用。
前一章 01-ingestion-buffer.md 讲了"为什么先攒再处理";这一章讲攒够之后那一下—— 一批聊天 blob 怎么变成用户画像的一次更新。这是整个 Memobase 工程含量最高的一环。
1. 这是什么(先建直觉)
一句话
把一段对话,变成对"这个人是谁"的结构化认知,并智能地并进已有认知里。
你和 AI 聊了十几轮,里面零散提到:喜欢诺兰的电影、在准备期末考、最近在用 Duolingo 学日语。 这一章的管线要做的,就是把这些散落的信息抽成一条条带主题的事实,再判断每一条:
- 是全新的信息 → 新增一条画像;
- 和已有画像冲突或更精确 → 更新那条;
- 已经包含在旧画像里、或没价值 → 丢弃。
为什么不能"一步到位"
直觉上你可能想:直接把聊天丢给一个 LLM,让它输出"更新后的完整画像"不就好了? Memobase 没这么做,而是拆成三次职责单一的调用,原因是:
| 拆开的好处 | 说明 |
|---|---|
| 每步 prompt 更聚焦 | "摘要"、"抽取"、"合并"是三种不同能力,分开写 prompt 各自更准 |
| 可并行 | 抽画像和打事件标签互不依赖,能 asyncio.gather 同时跑 |
| 成本可控 | 0.0.40 把调用数从"约 3-10 次"压到固定 3 次,token 成本降约 40-50%(见 readme.md:99 更新日志) |
"固定 3 次"到底是哪 3 次
| 第几次 | 干什么 | 入口函数 |
|---|---|---|
| ① | 聊天 → 结构化 memo | entry_chat_summary |
| ② | memo → 抽画像事实(并行支 A) | extract_topics |
| ③ | memo → 事件标签(并行支 B) | tag_event |
②之后紧跟的合并 merge_or_valid_new_memos 也会调 LLM,但它属于抽画像那一支的延续。后面 §3 会点明:
在快路径下,大量事实可以跳过校验、根本不触发那次合并调用——这正是"固定 3 次"能成立的关键。
organize_profiles(重组)和 re_summary(重摘要)只在槽位过大时偶发触发,不计入常规 3 次。
2. 顶层全景(一次 flush 怎么流)
主编排在 controllers/modal/chat/__init__.py:process_blobs(第 38 行)。它是这一章的"总控台"。
部件职责一览
| 部件 | 干什么 | 文件:符号 |
|---|---|---|
| 编排总控 | 串起摘要 → 并行两支 → 落库 | chat/__init__.py:process_blobs |
| ① 入口摘要 | 聊天 blob 摘成一份 memo | chat/entry_summary.py:entry_chat_summary |
| ② 画像抽取 | memo 抽事实、归一、合并、过滤 | chat/extract.py:extract_topics |
| 合并决策 | 对每条事实决定 UPDATE/APPEND/ABORT | chat/merge_yolo.py:merge_or_valid_new_memos |
| 重组 | 某主题槽位太多时重整子主题 | chat/organize.py:organize_profiles |
| 重摘要 | 单条画像文本太长时压缩 | chat/summary.py:re_summary |
| ③ 事件打标 | 给这段 memo 打事件标签 | chat/event_summary.py:tag_event |
| 落库 | 事件入库 + 画像增删改入库 | chat/__init__.py:handle_session_event / handle_user_profile_db |
管线图
从上到下是时间顺序。中间"并行两支"同时进行,靠 asyncio.gather 汇合。
(存储表结构见 03-storage-model.md,这里只画数据流。)
一批 chat blobs(已按 token 预算截断)
│
▼
┌─────────────────────────────────────┐
│ ① entry_chat_summary [LLM 调用 1] │
│ 聊天 → 结构化 user_memo_str │
└─────────────────────────────────────┘
│ memo 为空则直接返回空结果
▼
══════════ asyncio.gather 并行 ══════════
│ │
▼ 支 A:画像 ▼ 支 B:事件
process_profile_res process_event_res
┌─────────────────────────┐ ┌────────────────────────┐
│ ② extract_topics [LLM 2]│ │ ③ tag_event [LLM 3] │
│ 抽 fact / 归一 / 合并 │ │ memo → 事件标签 │
│ / allowed 过滤 │ └────────────────────────┘
│ │ │ │
│ ▼ │ │
│ merge_or_valid_new_memos│ │
│ UPDATE/APPEND/ABORT │ │
│ (validate 快路径可跳过)│ │
│ │ │ │
│ ▼ │ │
│ organize / re_summary │ │
│ (仅槽位过大时偶发) │ │
└─────────────────────────┘ │
│ intermediate_profile, delta │ event_tags
│ │
▼───────────────── 汇合 ────────────────▼
│
▼
handle_session_event → 事件 + profile_delta 落库,得 event_id
│
▼
handle_user_profile_db → add / update / delete 画像落库
│
▼
ChatModalResponse(event_id, add/update/delete_profiles)
主线走一遍(高层,不进代码)
process_blobs 里从上到下就是:
- 截断 ——
truncate_chat_blobs按 token 预算保留最近的 blob(__init__.py:23)。 - 读上下文 —— 拉项目画像配置和用户当前画像(
get_project_profile_config/get_user_profiles)。 - ① 出 memo ——
entry_chat_summary得到user_memo_str;若为空,直接返回空响应短路收工(__init__.py:65-73)。 - 并行两支 ——
asyncio.gather(process_profile_res, process_event_res)(__init__.py:75)。 - 落库 —— 先
handle_session_event落事件拿event_id,再handle_user_profile_db落画像增删改。
3. 逐个机制(由浅入深)
3.1 ① 聊天 → memo:entry_chat_summary
要解决的小问题: 原始聊天又长又啰嗦,还夹着助手的话。后面每一步都不该直接吃生聊天,而该吃一份干净、以用户为中心的摘要。
思路: 先做一次"归纳",把多轮对话压成一份 Markdown memo,顺带按项目配置的主题/事件维度去组织。这样抽取和打标都基于同一份精炼素材,省 token 也更准。
真实实现: chat/entry_summary.py:entry_chat_summary(第 15 行)。
- 它先断言所有 blob 都是 chat 类型(
entry_summary.py:22),再用pack_current_user_profiles打包"用户已有哪些主题"作为提示; - 用
tag_chat_blobs_in_order_xml(blobs)把聊天按顺序包成 XML 喂给模型(entry_summary.py:42); llm_complete(..., temperature=0.2, model=CONFIG.summary_llm_model)(entry_summary.py:43-54)——低温求稳,用较便宜的 summary 模型。
产出的 user_memo_str 是后面两支并行共同的输入。
关键短路: 如果 memo 摘出来是空串,
process_blobs直接返回空的ChatModalResponse,后两次调用都不发生 (__init__.py:65-73)。也就是说"3 次"是上限;没内容时连 1 次抽取都省了。
3.2 ② memo → 画像事实:extract_topics
这是画像支的第一步,也是这一章信息密度最高的地方。它把一份 memo 变成一串**(topic, sub_topic, memo)** 三元组事实。
要解决的小问题: memo 是自然语言,画像库要的是结构化槽位。得让 LLM 把"我喜欢诺兰的电影"变成 interest / movie / ... 这样的条目。
四个动作依次发生(都在 chat/extract.py:extract_topics,第 26 行):
(1) 抽 fact —— 一次 llm_complete,系统提示来自 prompts/extract_profile.py 的 FACT_RETRIEVAL_PROMPT,把项目允许的主题清单塞进去(extract.py:43-55)。模型被要求既抽"明说的",也推断"暗示的"——extract_profile.py:39-43 的 DEFAULT_JOB 让它扮演"心理学家"。输出格式是 - TOPIC{tab}SUB_TOPIC{tab}MEMO 的列表,前面允许先写一段思考、用 --- 分隔(见 FACT_RETRIEVAL_PROMPT 的 Output template,extract_profile.py:75-81)。少样本 EXAMPLES(extract_profile.py:9-37)里那条"喜欢 Inception/Interstellar → 还能推断出粉诺兰"就是教它做推断。
(2) attribute_unify 归一 —— LLM 给的主题名大小写、空格不统一(Basic Info vs basic_info)。抽出来后对每条 fact 的 topic/sub_topic 都跑 attribute_unify(extract.py:85-87)。这个函数极简但关键:
# 真实源码 types.py:6,一行把主题名归一
def attribute_unify(attr: str):
return attr.lower().strip().replace(" ", "_") # 小写 + 去空格 + 空格转下划线
归一是后面合并能"对上号"的前提——只有键统一了,新事实才能和旧画像用 (topic, sub_topic) 精确匹配。
(3) merge_by_topic_sub_topics 合并 —— 同一次抽取里,LLM 可能对同一个 (topic, sub_topic) 吐出多条。用 merge_by_topic_sub_topics(extract.py:15-23)把它们的 memo 用 ; 拼成一条,避免重复条目。注意这是本批内的去重合并,和跨批次的 §3.3 合并是两回事。
(4) allowed_topic_subtopics 过滤 —— 严格模式(strict_mode)下,只保留落在项目预定义主题集合里的事实。extract.py:94-99 里,若 (topic, sub_topic) 不在 allowed_topic_subtopics 就 continue 跳过。这个允许集在 chat/utils.py:pack_current_user_profiles 里按 strict_mode 构建(utils.py:36-44);非严格模式下该集合是 None,不过滤。