PostHog Notebook 组装实战:把 MCP 工具使用动机分类学发布为可独立交付的分析产物
【免费下载链接】posthog:hedgehog: PostHog is the leading platform for building self-driving products. Our developer tools – AI observability, analytics, session replay, flags, experiments, error tracking, logs, and more – capture all the context agents need to diagnose problems, uncover opportunities, and ship fixes. Steer it all from Slack, web, desktop, or the MCP.项目地址: https://gitcode.com/GitHub_Trending/po/posthog
本文是 PostHog 内部分析技能exploring-mcp-tool-original-user-motive(“探究 MCP 工具的原始用户动机”)的 Notebook 组装指南。该技能回答“人们为什么使用这个 MCP 工具”——通过从会话开头的工具调用序列重建用户动机、聚类成主题,并把结果以 PostHog Notebook 的形式发布。本篇聚焦其中决定成败的一环:把已标注的分类学数据组装成一个可分享、可审计、无需凭据的 Notebook。读完你将掌握 Notebook 的细胞编排顺序、计算资源配置、隐私分层的 SQL 写法、帧物化(frame materialization)上限的底层原理,以及一整套经真实运行验证的坑与对策。
Notebook 是交付物:三条铁律
Notebook 是分析流程的最终交付物,因此它必须能独立站住脚:一个月后打开它的人,应该能直接看到语料(corpus)、看到每个意图(intention)覆盖了哪些会话、并且可以在不同意某个主题(theme)划分时,不必追问“这是怎么做的”就能自行核查。
组装时记住三条铁律:
- Notebook 本身不需要任何凭据。每一个细胞要么是 SQL,要么是一小段 pandas 重塑(reshape);唯一需要模型的那一步——facet 抽取——早在 Notebook 存在之前,就已经在你的上下文中完成了(见技能步骤 4)。
- 细胞必须按依赖顺序排列:一个细胞读取另一个细胞的 dataframe,就必须等那个细胞先跑完。
- 可分享意味着可被任何人读取:任何放进 Notebook 的数据都默认公开给持有链接的人,隐私边界由此划定。
细胞顺序总览
Notebook 的组装顺序如下(来自 SKILL.md 步骤 5 的 12 步形状,本指南的 references 文档是其细胞级细化):
notebooks-create-markdown— 标题 + 一段简短的方法说明notebooks-configure-compute— 4 核 / 8 GB,必须在第一个 Python 细胞之前notebooks-add-cell(sql) — 语料:每个会话一行,携带 caller 与 orgnotebooks-add-cell(sql) — 工具级 caller 占比,基于$mcp_tool_call,让被过滤掉的自动化流量仍然可见notebooks-add-cell(python) — 每会话一行的 facets,以sid为键:(sid, starting_intention, theme, data_touched, <第三维度>)notebooks-add-cell(sql) — 起始意图表,对该 frame 做GROUP BYnotebooks-add-cell(sql) — 主题表,同一 frame 再上一级,列出每个主题下包含的意图notebooks-add-cell(python) — caller 与 org 按会话展开,第二个以同一sid为键的字面量notebooks-add-cell(sql) — 每组织意图分布,join 上述两个 frame,携带主题notebooks-add-cell(sql) — 浓度检查(技能步骤 6)notebooks-add-cell(markdown,可选) — 示例会话解析为 trace URL(见 SKILL.md “Linking an intention to real sessions”)notebooks-add-cell(markdown) — 发现与结论,以及步骤 3 的偏斜修正
要点:细胞 6 和 7 不携带 caller 和 org。分类学回答的是“人们来做什么”;把人口统计列混进去,等于在一张表里回答两个问题,两个都答不好。细胞 8 才是两个维度交汇的地方,也应该是唯一交汇的地方。
第 1 步:创建 Notebook —— 先把数字校准,再写一个字引言
Notebook 的开篇段落会写明语料规模,而这是唯一一个之后无法修改的细胞:markdown 细胞没有可寻址的node_id,数字一旦写错,就意味着销毁重建整个 Notebook。
因此:先让语料查询和 caller-share 查询互相校验,只引用你实际分析的窗口内得出的数字(SKILL.md 步骤 2 明确指出:绝不要从更早的 sizing 查询取数——曾经有一次头部写着 520 会话 / 507 organic,而实际窗口只有 237 / 233,因为 sizing 查询用的是 30 天、语料用的是 14 天,两个数字都是真的,只是描述的不是同一件事)。
创建调用示例:
notebooks-create-markdown { "title": "Why people use <tool>", "markdown": "Starting-point taxonomy for `<tool>`, 90 days to <date>.\n\nEach session's goal is reconstructed from its opening tool calls, then clustered on goal text. Clusters below 5 sessions are suppressed. No customer names or raw intents appear below." }返回notebook_id——这是 URL 里的短 id,后续所有调用都依赖它。
第 2 步:在第一个 Python 细胞之前提升计算资源
默认配置是 1 核 / 2 GB,只能启动内核、做不了多少事;对几百个短字符串做凝聚聚类(agglomerative clustering)会一直卡在那里。所以要在第一个 Python 细胞之前执行:
notebooks-configure-compute { "short_id": "<id>", "cpu_cores": 4, "memory_gb": 8 }务必“先”做。如果内核已经在运行,响应会置restart_required,而重启会丢弃所有已物化的 dataframe。这也是为什么技能要求它在步骤 2(创建)之后、步骤 3(第一个 SQL 细胞)之前就执行——越早越好。
第 3 步:语料细胞(SQL)——可分享版本的查询
绝对不要把 SKILL.md 步骤 2 的查询原样粘进这个细胞。那条查询把原始的$mcp_intent文本拼接起来供你自己阅读,而 Notebook 是可分享的:任何拿到链接的人都能读到细胞返回的任何内容,包括其中的客户名、项目 id 和粘贴进来的凭据。步骤 2 的查询只服务于你自己的阅读上下文,到此为止。
发布版是一个更窄的查询——同一批会话,但意图文本被替换为工具名本身:
-- inside the steps CTE, instead of concat(tool, ': ', substring(intent, 1, 130)) coalesce(nullIf(toString(properties.$mcp_tool_name), ''), toString(properties.tool_name)) AS step其余部分完全相同。发布时用dataframe_name: "corpus",标题类似 "Sessions that reached the tool"。结果是sid、caller、org和一个opening_tools序列(如execute-sql > read-data-schema > workflows-list),足以审计分类学覆盖了哪些会话,且不携带任何客户文本。这是 Clio 式分层隐私的第 4 层,也是最容易因为“把上面的查询粘进细胞”而丢失的一层。
第 4 步:facets 细胞(Python)——每会话一行,以 sid 为键
每个会话内联一行,以sid为键,让 corpus frame 提供 caller 和 org。
先澄清一个反直觉的权衡:按不同 facet 组合预先聚合会更小——一个 500 会话的语料会塌缩到约 120 行、约 8 KB 源码,而每会话一行是 30 KB。但这是错误的取舍:一旦分析需要 org,组合键就会膨胀回大约会话数(因为大多数 org 只持有一个会话)。以sid为键,字面量可以和任何东西 join,转写时也不需要数数。
**起始意图是主表。**主题是手工赋值的列,不是计算出来的列(见下文“为什么分组要手工”)。
# One row per session: sid|starting intention|theme|data_touched|destination. # # The starting intention is the goal the person held before any tool was chosen. # It is not a recorded property. $mcp_intent records the action an agent took, # so each label here was written by reading that session's opening calls. # Proper nouns were stripped at that point, not later. DATA = ''' a1b2c3d4|build a recurring metrics digest|recurring reporting|1|slack e5f6a7b8|fix a misfiring workflow|maintenance|0|unclear ''' import pandas as pd MIN_SESSIONS = 5 COLUMNS = ["sid", "starting_intention", "theme", "data_touched", "destination"] facets = pd.DataFrame([line.split("|") for line in DATA.strip().split("\n")], columns=COLUMNS) facets["data_touched"] = facets["data_touched"].astype(int) assert len(facets) == 428, f"expected 428 sessions, got {len(facets)}" assert facets["sid"].is_unique, "a sid appears twice" kept = facets.groupby("starting_intention").filter(lambda g: len(g) >= MIN_SESSIONS) print(f"{len(facets)} sessions, {facets['starting_intention'].nunique()} distinct intentions") print(f"{kept['starting_intention'].nunique()} at or above {MIN_SESSIONS} sessions " f"({len(kept)} of {len(facets)} sessions kept)") facets这里没有 TF-IDF 步骤。早期版本确实用聚类来构建主题,现在它是手工赋值的列,因为按 org 的表格读取主题列,任何词法层面的错配都会传播进从它派生的每一个数字。下面保留的实测错配,是作为“原因”而非“免责声明”呈现的。
为什么分组要手工
技能只在scripts/audit_intentions.py中嵌入一次意图向量(用于捕捉漂移),刻意不用嵌入来构建主题。三个原因,按权重排序:
**输入已经是规范化的。**抽取把几百个会话塌缩到一个小的受控词汇表上,嵌入本应恢复的语义方差,在它们看到数据之前就已被移除。在workflows-create语料上实测:105 个意图两两之间的最高相似度只有 0.819——它们本就分得很开。
分组是有目的的,而嵌入看不到目的。verify a feature flag configuration(核对配置)属于分析工作,因为它在检查产品状态;它不属于manage feature flag rollout(管理发布),因为后者在改变状态。嵌入每次都会把这两个含 flag 的短语放一起。语义上正确,分析上错误。
**它恢复了那条警示。**嵌入分组比词法分组好得多,但依然不可审计;一旦 per-org 表格按主题计算,任何坏合并都会传播进每个派生数字。
两个条件会翻转这个决定:意图数量达到几千个时,手工分组不再可行;如果分类学要成为跨窗口对比的常设报告,确定性分组就优于更好但不可复现的分组——否则运行间的漂移会与用户行为的漂移无法区分。
**只要有任何东西挂在分组上,就手工赋主题,不要聚类。**TF-IDF 按共享词合并而非共享语义合并,在短规范字符串上会明显出错——它把investigate a production error并进analyze revenue attribution(因为都含 "investigate"),把migrate feature flags并进measure feature usage volume(因为都含 "feature")。当聚类只是装饰时可以记录这些错误;一旦 caller breakdown 或其他任何切分按聚类计算,错误就会传播进每个派生数字,就再也不能记录了。
抽取已经应用了语义判断来产出意图。用同样的判断把 70 多个短短语分成十几个主题成本很低,而且消除了一整类警示:
THEMES = { "recurring reporting": ["build a recurring metrics digest", "report on a launch", ...], "automated monitoring": ["run a scheduled anomaly scout", ...], } theme_of = {intent: theme for theme, intents in THEMES.items() for intent in intents} unmapped = sorted(set(facets["starting_intention"]) - set(theme_of)) assert not unmapped, f"intentions with no theme: {unmapped}" assert len(theme_of) == sum(len(v) for v in THEMES.values()), "an intention appears in two themes"在 Notebook 中明确说明分组是手工的。不同意的读者可以退回意图表——无论哪种分组方式,意图都是原始单元。
MIN_SESSIONS同样适用于意图表。一个只有一次会话的意图就是一个“单例聚类”,这正是聚合阈值要防止的东西——即使字符串本身不含专有名词。
**在细胞里断言语料规模。**你在把一百多行数好的行转写进一个工具调用,漏掉或打错一行会在不出错的情况下改变表中的每一个占比:
assert len(facets) == 428, f"expected 428 sessions, got {len(facets)}"这个断言真的抓住过一次失误——漏了一行、四个计数打错,表现为 418 而不是 428。每一个百分比都是错的,而没有任何东西报错。SKILL.md 还建议在两个手写字面量之间加assert set(population['sid']) == set(facets['sid']),这是唯一能抓住跨字面量转写失误的检查,成本极低。
4b:把人口数据作为第二个字面量发布,而不是 join 查询
Python 细胞发布facets(以sid为键)。caller 和 org 作为第二个 Python 字面量到达(同样以sid为键),每张人口统计表都是一个 join 二者的 SQL 细胞:
SELECT p.org, f.theme, f.starting_intention, count(*) AS sessions FROM facets AS f JOIN population AS p ON f.sid = p.sid GROUP BY p.org, f.theme, f.starting_intention ORDER BY sessions DESC**两个字面量都是可执行源码,所以没有任何不受信任的东西能原样进入它们。**caller 和 org 来自客户端可控的属性,一个包含'''的值会闭合字面量,并在细胞运行时执行其后的内容。技能的语料查询把二者约束到安全字符集,对其他任何值输出unsafe-caller-value;转写那条查询返回的内容,绝不要手工放宽它。目标标签走另一条安全路径——是你自己写的。
**join 语料细胞是行不通的,原因值得在设计前了解。**一个被其他细胞 join 的 SQL 细胞必须物化进 Notebook 内核,而物化在自己的上限下运行(见 frame_materialize.py):50 GB 扫描预算、2 GB 结果上限、50 万行、16 线程。一个把 90 天$mcp_tool_call按 session id 分组的语料查询会撞爆它们,细胞以如下错误失败:
This query exceeds the frame materialization limits (scan or memory budget). Narrow it and re-run.关于这条消息有三件事值得注意:
- **它不说你撞的是哪个预算。**同一个字符串由 ClickHouse 错误码 158
TOO_MANY_ROWS、241MEMORY_LIMIT_EXCEEDED和 307TOO_MANY_BYTES映射而来。时间限制和 2 GB 结果上限各有独立消息,所以收到这条消息排除了那两个,仅此而已。在 frame_materialize.py 中可以看到这组消息与错误码的完整映射(_RESOURCE_BUDGET_MESSAGE对应 158/241/307,时间预算对应 159/160,结果尺寸对应 396)。 - **收窄查询未必有用。**50 GB 很慷慨,而对项目内每个会话做高基数的
GROUP BY再 joinperson.properties,是内存形状而非扫描形状。把语料查询改写成单趟、不带 session 子查询,仍然同样失败——这正是内存受限失败的样子。 - **不要为了腾出余量而收窄时间窗口。**那会改变语料包含哪些会话,而标注过的 frame 是固定快照,两者从此不再描述同一人群。这个交易永远不值得。
语料细胞仍然有价值:它是同一两列的、可审计的副本。它只是不能做 join 源。join 细胞里保持可移植 SQL(sum(case when ... then ... else 0 end),用min(col)而非any(col)),让同一查询无论跑在哪个引擎上都成立。
4c:把 ClickHouse 细胞钉在绝对时间戳上
两个 ClickHouse 细胞——语料和 caller share——都应该用显式的timestamp >= toDateTime(...) AND timestamp < toDateTime(...)范围,而不是now() - INTERVAL 90 DAY。
标注过的 frame 是读取语料时拍的快照,滚动窗口会一直在它下面移动。在workflows-create运行中,实时计数在 Notebook 构建的短短几个小时里从 761 漂移到 769,而标签保持在 719。头部细胞写明了语料规模,而 markdown 细胞之后无法编辑,所以漂移一旦出现就无法修复。
选取上界时,让钉住的查询能复现你标注的计数,并在写引言前验证:那次运行中,精确返回 761 / 734 / 719 / 340 的截断点比 sizing 查询自己的时钟早了两个小时。
4d:人口统计表
有三张表值得发布,且它们都不属于意图表或主题表:
- **工具级 caller 占比。**每个 caller 一行,表示为全部会话中的占比和 organic 会话中的占比。用基于
$mcp_tool_call的 ClickHouse 细胞,而不是基于 facets frame,这样你过滤掉的自动化流量仍然可见。 - **每组织意图分布。**来自 4b 的 join,携带主题。用它判断一个主题是“模式”还是“一个客户在重复”。
- **浓度。**技能步骤 6 的检查,在 caller 和 org 两个维度上跑。
caller 的集中程度远超总计所暗示的,这正是重点。在notebooks-create运行中:automated monitoring 94% 是 PostHog Desktop,product analysis 和 feedback review 都超过 60% 是 Claude Code,web performance 43% 是 plugin;customer and account analysis 68% 是 Cowork 和 Claude.ai 合计、只有 6% 是 Claude Code——销售工作流与工程工作流完全在不同的表面上。
要找的结论是:存在一个人群还是多个。如果主题按 caller 干净地切分,就没有“唯一用户”可设计,这一点值得直说。
**把unattributed当作缺口,而不是 caller。**一个看似集中在那里的主题,缺的是埋点,而不是人群。把这个说明写在数字旁边,否则它会被读成一个发现。
org 是两个维度中更可靠的那个。$mcp_organization_id在 90 天workflows-create语料的每个会话上都设置了,而 caller 属性只有约三分之二的会话有。当两者对流量集中程度的判断不一致时,信 org。
**分析表跑在 org id 上,而不是名字上。**它们需要知道两个会话属于同一客户,不需要知道是哪个客户。如果分析的目的就是决定该找谁谈,把名字解析放在一个单独标记为“可识别客户”的细胞里,让这些表保持在 8 字符前缀上——见技能中的 “Default to the org id”。
第 5 步:示例 Trace(可选)
如果分类学需要证据支撑,每个意图解析一两段会话到 AI observability trace,用技能 "Linking an intention to real sessions" 里的 join——注意 join 必须从 trace 侧发起($mcp_tool_call不带$ai_trace_id,而$ai_generation带$mcp_session_id)。
每一行都带上会话计数和占比,与意图表相同的两列。没有它们,表格读起来像每个意图一样常见;更重要的是,它掩盖了一行是否跨过了聚合下限。
**只列出达到MIN_SESSIONS的意图。**在稀有意图下列出带名字的会话,正是阈值要防止的事:组越小,链接越容易识别出某人。这一点很容易做错,因为 trace 最有趣的意图往往是最稀有的。这张表的第一版带了 2、3、4 个会话的三个意图,只有加上计数列才让问题显形。
保留$mcp_session_id和 trace URL,把原始$mcp_intent排除在外。id 是不透明的;意图则逐字携带客户、项目和产品名。
在表里说明链接打开的是什么:PostHog 在该会话期间的服务端查询工作,而不是用户的对话。
第 6 步:结论细胞(Markdown)
把占比写成散文,从最大的起始意图开始。包含偏斜修正——如果某个关键词过滤器漏了,在这里陈述修正后的占比,而不是悄悄使用过滤后的数字。如果 rollup 合并了无关意图,说明这一点,而不是把合并后的聚类当作一个数字引用。
为什么配方删掉了聚类步骤
早期版本确实对目标字符串跑 TF-IDF 和凝聚聚类来构建主题。下面的实测就是为什么那一步被删除而不是被调优。
**摘要步骤已经完成了几乎全部工作。**抽取按设计把每个会话映射到一个小型共享词汇表——一次 499 会话的运行只产生 73 个不同的目标字符串。等到聚类器看到它们时,分组已经发生了。它只是第二次、更松散的合并,把相关标签并到一起,而不是发现结构的步骤。
TF-IDF 按共享词而非语义合并,在短规范字符串上会明显出错。notebooks-create运行中的真实合并:
| Merged into | Wrongly absorbed | Shared token |
|---|---|---|
| investigate attribution tracking | investigate a production error (4) | "investigate" |
| measure feature usage volume | migrate feature flags (1), manage feature flag rollout (1) | "feature" |
| run a product health review | evaluate a product for adoption (3) | "product" |
提高聚类数可以拆开其中一部分,但永远修不干净——失败是词法层面的,而不是分辨率问题。手工赋值主题列,在 70 多个短短语上花几分钟,就消除了整类错误。
audit_intentions.py(源码)是整个流程里唯一的嵌入步骤:它用text-embedding-3-small嵌入每个不同意图并打印最接近的对(默认阈值 0.80,是校准过的——105 个意图的最高对只有 0.819,阈值再高就什么都标不出来了)。它故意不合并任何东西,因为语义接近不是合并指令。这印证了“分组靠手、去重靠审计”的分工。
Gotchas:从真实运行中沉淀的坑
**dataframe_name发布的是细胞最后一个表达式,而不是它命名的那个变量。**以clusters.sort_values("sessions").head(20)结尾,发布的就是那 20 行,下游细胞读到的 frame 只有这些。以裸 dataframe 结尾。下游空 frame 会以InvalidInputException: Need a DataFrame with at least one column失败,完全指不回这里。
参数名在工具之间会变。notebooks-add-cell收notebook_id;notebooks-run-cell-result和notebooks-configure-compute收short_id。同一个值,不同的键。
第一个 Python 细胞很慢,因为沙箱内核正在启动。notebooks-add-cell大约要等 45 秒,可能返回status: running;用run_id和short_id一起轮询notebooks-run-cell-result,间隔几秒。
**轮询可能比真相滞后几分钟。**有一次运行 5 秒就完成了,notebooks-run-cell-result却继续报running大约三分钟。notebooks-list-frames立刻显示了完成后的 dataframe,带真实的列清单和行数。轮询看起来卡住时,先查 frames 再考虑notebooks-run-cell-interrupt——打断一个已经成功的细胞,会白白损失内核状态。
**一个表达式里求两次any()不会返回同一行。**写成multiIf(any(consumer) != '', concat('consumer:', any(consumer)), ...)的 caller 列产生了带空值的前缀consumer:——条件看到了非空行,分支却看到了空行。在内层查询里计算聚合,把multiIf放到外层SELECT。
**notebooks-add-cell对 markdown 细胞收markdown,不收code。**传code会以A markdown cell requires non-empty markdown失败。SQL 和 Python 细胞收code。
**Python frame 活在内核里,它消失后 join 它的 SQL 细胞就会失败。**错误是Input registration failed: "local frame 'facets' is not in the kernel — run the node that creates it first",即使 Python 细胞已报done且带行数也会出现,因为 frame 没活到 join 那一刻。notebooks-list-frames对此是诚实的:frame 真正在的时候才列出它。重跑 Python 细胞,再跑 SQL 细胞,并读更新响应里的stale_dependents看还有什么需要重跑。
如果细胞挂起,notebooks-run-cell-interrupt可以清掉它。内核已进入stopped但运行仍显示running时,必须先用 interrupt,否则任何东西都不会继续执行。
用notebooks-update-cell迭代,而不是每次改查询就新增一个细胞——否则 Notebook 会积累死尝试,读者得一路翻过去。注意notebooks-update-cell只收code,所以发布后再重命名 frame 意味着删除细胞重新添加。
前置工作回顾:这些数据从哪来
本指南假设你已完成技能的前置步骤:用 SKILL.md 步骤 2 的语料查询读取会话开头调用(4~5 个调用是工作默认值,opening列携带客户文本、只读不发布)、在步骤 3 检查偏斜(用 caller 拆分而非意图关键词)、在步骤 4 为每个会话抽取 facet。抽取可用 extract_facets.py(gpt-4.1-mini、8 并发、固定响应 schema;注意其 docstring 要求只对已同意 AI 数据处理的组织运行,因为opening列是客户撰写的文本,会发送给第三方模型,与后端 intent_generation.py 的is_ai_data_processing_approved门槛对齐)或自己逐会话阅读——技能基于实测推荐后者:脚本式抽取在 500 会话上产生 487 个不同标签,且把“修复一个误触发的 workflow”(37 会话、7.4%)压成“更新 workflow 内容”(4 会话、0.8%)。第三个 facet 的选择参考 facet-schemas.md(notebook_role / destination / edit_scope / depth 等按工具形态选)。
组装完成后,Notebook 就是最终答案:语料可审计、意图是原始单元、主题是手工赋值、每个占比都有断言兜底、任何访客无需凭据即可复现阅读——这正是“为什么有人使用这个工具”这个问题的、可交付的、诚实的答案。
【免费下载链接】posthog:hedgehog: PostHog is the leading platform for building self-driving products. Our developer tools – AI observability, analytics, session replay, flags, experiments, error tracking, logs, and more – capture all the context agents need to diagnose problems, uncover opportunities, and ship fixes. Steer it all from Slack, web, desktop, or the MCP.项目地址: https://gitcode.com/GitHub_Trending/po/posthog
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考