跳到主要内容

核心:三次 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 次

第几次干什么入口函数
聊天 → 结构化 memoentry_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 摘成一份 memochat/entry_summary.py:entry_chat_summary
② 画像抽取memo 抽事实、归一、合并、过滤chat/extract.py:extract_topics
合并决策对每条事实决定 UPDATE/APPEND/ABORTchat/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 里从上到下就是:

  1. 截断 —— truncate_chat_blobs 按 token 预算保留最近的 blob(__init__.py:23)。
  2. 读上下文 —— 拉项目画像配置和用户当前画像(get_project_profile_config / get_user_profiles)。
  3. ① 出 memo —— entry_chat_summary 得到 user_memo_str;若为空,直接返回空响应短路收工(__init__.py:65-73)。
  4. 并行两支 —— asyncio.gather(process_profile_res, process_event_res)(__init__.py:75)。
  5. 落库 —— 先 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.pyFACT_RETRIEVAL_PROMPT,把项目允许的主题清单塞进去(extract.py:43-55)。模型被要求既抽"明说的",也推断"暗示的"——extract_profile.py:39-43DEFAULT_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_subtopicscontinue 跳过。这个允许集在 chat/utils.py:pack_current_user_profiles 里按 strict_mode 构建(utils.py:36-44);非严格模式下该集合是 None,不过滤。

最终 extract_topics 返回 fact_contents(memo 文本列表)、fact_attributes(topic/sub_topic 列表)、profiles(用户现有画像)、total_profiles(项目槽位定义)四样,喂给合并(extract.py:107-114)。


3.3 合并决策:merge_or_valid_new_memos(UPDATE / APPEND / ABORT)

要解决的小问题: 新抽的每条事实,和用户已有的同槽位画像怎么处理?是覆盖?追加?还是这信息旧画像早有了、直接扔?

思路: 对每条新事实,先找它对应的"旧画像",连同主题描述和更新要求一起交给 LLM,让 LLM 三选一决策。真实实现在 chat/merge_yolo.py:merge_or_valid_new_memos(第 14 行)。

两张 map 是核心数据结构:

map含义
DEFINE_MAPS(topic, sub_topic)SubTopic项目定义的槽位:带 description / update 说明 / validate 开关(merge_yolo.py:26)
RUNTIME_MAPS(topic, sub_topic)ProfileData用户当前实际存着的画像(merge_yolo.py:29-32)

对每条新事实,用它的键去两张 map 各查一次:runtime_profile 是"现在存的内容",define_sub_topic 是"这个槽该怎么维护"(merge_yolo.py:49-55)。

validate_mode 快路径(为什么常规只有 3 次调用):

不是每条事实都值得花一次 LLM 去决策。当满足三个条件——没开全局校验、该槽位没要求校验、且用户此前没有这条画像——就直接当"新增"处理,根本不进 LLM(merge_yolo.py:56-72):

# 简化自 merge_yolo.py:56-72,示意快路径判断
if (not PROFILE_VALIDATE_MODE # 全局没强制校验
and not define_sub_topic.validate_value # 这个槽位也没要求校验
and runtime_profile is None): # 用户还没有这条画像
results["add"].append({"content": f_c, "attributes": f_a}) # 直接新增,跳过 LLM
continue

只有没走快路径的事实,才被打包成 new_memos_input 发给 merge_profile_yolo 的一次 llm_complete(merge_yolo.py:87-94)。若一批事实全走快路径,这次合并调用就不发生——这正是"固定 3 次"的实现基础。

三种动作怎么落到结果里:

LLM 的回复用 parse_string_into_merge_yolo_action 解析成 {memo_id: {action, memo}}(merge_yolo.py:103,解析器在 prompts/utils.py:168)。动作定义见 prompts/merge_profile_yolo.pyMERGE_FACTS_PROMPT:

动作prompt 里的含义代码里怎么处理
UPDATE新信息更精确或冲突,重写整条 memo有旧画像→改它、update_hits+1;无→当新增(merge_yolo.py:118-143)
APPEND是新增见解,直接补上有旧画像→旧内容;新内容拼接、update_hits+1;无→新增(merge_yolo.py:144-169)
ABORT无价值或旧画像已含记进 abort_infos,啥也不落(merge_yolo.py:170-171)

update_hits 计数: 每次一条画像被 UPDATE 或 APPEND 命中,就把它 attributes 里的 update_hits 加一(merge_yolo.py:127-130153-156)。这是"这条画像被反复印证了多少次"的热度信号,检索/排序时会用到(见 04-retrieval-context.md)。

结果结构 MergeAddResult(chat/types.py:27-36)分五格:add / update / delete / update_delta / before_profiles。其中 update_delta 只记"这次新增了什么内容"(不含拼接后的全文),供事件的 profile_delta 用。


3.4 重组与重摘要:organize_profiles / re_summary(偶发)

这两步是画像支的"垃圾回收",只在数据长胖时触发,不属于常规 3 次调用。

organize_profiles —— 某主题子槽太多时重整。 一个 topic 下的 sub_topic 数量超过 CONFIG.max_profile_subtopics(默认 15,env.py:88)就触发(chat/organize.py:30-34)。对超标的每个主题,organize_profiles_by_topic 把该主题所有画像丢给 LLM 重新归并成更少的子主题(organize.py:61-123),再把旧的标记删除、新的加入,并用 deduplicate_profiles 去重(organize.py:48-57)。重整后强制压到 max_profile_subtopics // 2 + 1 条以内(organize.py:122)。

re_summary —— 单条画像文本太长时压缩。 遍历新增和更新的画像,summary_memo 只对超过 CONFIG.max_pre_profile_token_size(默认 128 token,env.py:89)的那条调用 LLM 重摘要,再截到半个预算(chat/summary.py:29-53)。没超长的直接跳过、不调 LLM(summary.py:33)。

这两步任一失败都只 TRACE_LOG.error 记一笔、不中断主流程(__init__.py:161-180)——画像更新是尽力而为,重组失败不该让整次 flush 失败。


3.5 ③ 事件打标:tag_event

这是并行的另一支,和画像抽取同时跑。

要解决的小问题: 除了"这个人是谁"(画像),Memobase 还想记"这次聊了什么事"(事件),并给事件打上可检索的标签。

真实实现: chat/event_summary.py:tag_event(第 14 行)。

  • 先读项目配置的事件标签定义 read_out_event_tags;若一个标签都没配,直接返回 None,连 LLM 都不调(event_summary.py:19-20)——这也是省调用的一处。
  • 否则把标签清单塞进 event_tagging prompt,llm_completeCONFIG.best_llm_model 打标(event_summary.py:22-29)。
  • 解析后再做一次严格过滤:只保留 tag 落在项目已定义标签集合里的(event_summary.py:37-39),防止 LLM 编出配置里没有的标签。

产出的 event_tags 会随 memo 一起进 handle_session_event,和画像的 update_delta 一并作为这次事件的记录落库。


4. 落库这一下(边界,不展开表结构)

并行两支汇合后,process_blobs 做两次落库(表结构细节留给 03-storage-model.md):

  1. handle_session_event(__init__.py:204)—— 把 memo_strevent_tagsprofile_delta(即 delta_profile_data)打成一个事件,append_user_event 入库,返回 event_id。其中 delta_profile_data = add + update_delta(__init__.py:150-152)——只记这次实际变化的内容。
  2. handle_user_profile_db(__init__.py:226)—— 把 MergeAddResult 的 add/update/delete 三格拆成内容、属性、id 列表,交给 add_update_delete_user_profiles 一次性落库(__init__.py:235-244)。

最终返回 ChatModalResponse,带上 event_id 和这次动过的画像 id 列表(__init__.py:111-118)。


5. 巧妙之处(可带走的精华)

  • "固定 3 次"不是硬砍,是靠多条快路径叠出来的。 空 memo 短路(__init__.py:65)、合并的 validate 快路径(merge_yolo.py:56)、无标签直接返回(event_summary.py:19)、重摘要只压超长条(summary.py:33)——每处都在"没必要时不调 LLM"。上限是 3 次,实际常更少。
  • 归一(attribute_unify)是合并能对上号的隐形前提。 就一行 lower().strip().replace(" ","_")(types.py:6),但抽取、合并、重组、打标处处都调它,保证同一个槽位在任何环节键都一致。
  • 两张 map 的分工干净。 DEFINE_MAPS(该怎么维护)vs RUNTIME_MAPS(现在存了啥),把"规则"和"状态"分开,合并逻辑读起来一目了然(merge_yolo.py:26-32)。
  • UPDATE 用整段重写、APPEND 用分号拼接。 冲突信息让 LLM 重写全文(去冗余),纯增量信息直接拼接省一次改写——两种更新语义对应两种成本(merge_yolo.py:118-169)。
  • 副产品 update_delta 让事件只记"净变化"。 不把拼接后的全文塞进事件,只记这次新加的那点,事件流因此干净(merge_yolo.py:138-143)。

6. 边界与局限

  • 强依赖 LLM 输出格式。 抽取/合并/打标全靠解析 LLM 的文本(- topic{tab}sub_topic{tab}memoN. ACTION{tab}...)。格式跑偏的行会被解析器静默丢弃(prompts/utils.py:parse_line_into_profile 返回 None;merge_yolo 里 action 不在动作集就 continue),不会报错但会漏信息。
  • 合并里没匹配到动作只是记日志跳过。 若 LLM 漏答某个 memo_id,merge_yolo.py:107-114TRACE_LOG.warningcontinue,那条事实这次就不落库。
  • 重组/重摘要失败不阻断。 见 §3.4:尽力而为,失败仅记日志。
  • 这一章不覆盖: 画像/事件的存储表结构(见 03)、检索时怎么拼 context(见 04)、缓冲区何时触发 flush(见 01)。

7. 代码地图(导航索引)

主题文件符号
编排总控(3 次调用固定于此)controllers/modal/chat/__init__.pyprocess_blobs
画像支子流程controllers/modal/chat/__init__.pyprocess_profile_res
事件支子流程controllers/modal/chat/__init__.pyprocess_event_res
事件落库controllers/modal/chat/__init__.pyhandle_session_event
画像落库controllers/modal/chat/__init__.pyhandle_user_profile_db
① 入口摘要controllers/modal/chat/entry_summary.pyentry_chat_summary
② 画像抽取controllers/modal/chat/extract.pyextract_topics
本批内同槽合并controllers/modal/chat/extract.pymerge_by_topic_sub_topics
主题名归一memobase_server/types.pyattribute_unify
允许主题集 / 已有主题打包controllers/modal/chat/utils.pypack_current_user_profiles
合并决策(UPDATE/APPEND/ABORT)controllers/modal/chat/merge_yolo.pymerge_or_valid_new_memos
合并动作解析prompts/utils.pyparse_string_into_merge_yolo_action
重组(子槽过多)controllers/modal/chat/organize.pyorganize_profiles
重摘要(单条过长)controllers/modal/chat/summary.pyre_summary
③ 事件打标controllers/modal/chat/event_summary.pytag_event
抽取 promptprompts/extract_profile.pyFACT_RETRIEVAL_PROMPT / EXAMPLES
合并 promptprompts/merge_profile_yolo.pyMERGE_FACTS_PROMPT
结果与 prompt 映射类型controllers/modal/chat/types.pyMergeAddResult / PROMPTS