关于 debugging-surveys
面向 PostHog 支持工作的调查问卷诊断技能,帮助排查应用内问卷未显示或响应数据异常。重点分析资格检查、SDK 差异及响应处理流程。
主要功能
- 01比较各 SDK 的问卷支持情况
- 02追踪 Web 问卷资格检查
- 03诊断响应采集与合并
- 04解读分支逻辑和可选问题
- 05利用事件与追踪排查工单
适用场景
- 01查明问卷为何未显示
- 02解释移动 SDK 间的行为差异
- 03调查响应数量和答案缺失
MCP 相关
可通过 PostHog MCP 工具或调查问卷 API 检查问卷配置和响应;无需检出 PostHog 代码库。
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ posthog/ ai-plugin --skill debugging-surveys安装后在 Claude Code 中输入/debugging-surveys确认已加载。
附带文件references/diagnostic-queries.mdreferences/reading-responses.md
详细介绍
调试问卷
PostHog Surveys 是一款无代码的应用内表单构建工具。客户在 PostHog UI 中创建问卷;随后,应用运行的相应 SDK 必须对其进行评估和渲染。 大多数“问卷未显示”工单都是资格问题,而非渲染错误——SDK 正确地判定用户不符合资格,接下来要找出_哪个_条件未通过,以及_为什么_。
访问要求
使用 PostHog MCP 工具或问卷 API 检查问卷配置和回复。执行这些诊断步骤不需要检出 PostHog 仓库。
跨 SDK 功能对等情况(请先检查)
有一大类工单是“客户期望使用的平台不支持的功能”。请先确认问卷的 lib / 客户的平台,再查阅此表。已根据 SDK 源代码验证——如果距离上次验证已有数月,请重新核实;这些差异会随着时间推移逐渐消除。
| 功能 | Web(posthog-js) | iOS | Android | Flutter | React Native |
|---|---|---|---|---|---|
| 渲染 | DOM + shadow root | 原生 SwiftUI(SurveysWindow) | 无内置 UI — 仅 delegate | Dart widgets(SurveyBottomSheet) | RN 组件(SurveyModal) |
| 基于事件的触发条件 | 是(从 1.137.0 起,2024-06-05) | 是 | 是 | 是(原生端) | 是 |
| URL / 屏幕定位 | 是 | 已解码,但不会进行评估(// TODO) | 已解码,但不会进行评估 | 不会进行评估(原生端缺失) | 过滤器中明确排除 |
| 功能标记 / 队列定位 | 是 | 是 | 是 | 是(原生端) | 是 |
seenSurveyWaitPeriodInDays | 是 | 是 | 是 | 是(原生端) | 是(≥ 4.81.5) |
surveyPopupDelaySeconds | 是 | 未实现 | 未实现 | 未实现 | 未实现(TODO) |
enable_partial_responses | 是(≥ 1.240.0) | 否 | 否 | 否 | 否 |
skipSubmitButton(自动提交) | 是(≥ 1.244.0) | 否 | 否 | 否 | 否 |
最后两行依据的是编辑器自身的帮助文本(“目前不适用于移动 SDK” / “目前不支持移动 SDK”),而非对各 SDK 源代码的逐项审查。
值得牢记的后果:
surveyPopupDelaySeconds仅适用于 Web。 如果移动端工单将延迟归咎于它,这就是个误导信息。- 部分响应和自动提交也仅适用于 Web。 在移动端,问卷始终会在结束时存储一份响应,点击评分也不会自动提交。不要把 Web 端的诊断结论套用到移动端工单上。
- URL 定向实际上仅适用于 Web。 移动端 SDK 会解析该字段,但从不执行它;React Native 则会完全过滤掉这类问卷。带有 URL 条件的移动端问卷,其行为会是“无 URL 条件”(mobile/flutter)或“永不显示”(RN)。
- Android 不提供问卷 UI。 应用(或 Flutter 插件)必须提供一个
PostHogSurveysDelegate。“问卷从未在 Android 上渲染”通常是因为缺少委托,而非 PostHog 的错误。 - **Flutter 是混合架构:**触发和资格判断在原生 iOS/Android 层运行;渲染则由 Dart 负责(
SurveyService.showSurvey→showModalBottomSheet)。UI 并非“直接调用原生”实现。因此,Flutter 渲染问题出在 Dart;Flutter 资格判断问题出在原生层。 - 从 4.81.5 开始,React Native 会强制执行等待期。 较旧版本会跳过检查,因此遇到这些版本时,不要将问题归咎于等待期。
如需深入了解各版本的功能审计,请参阅 survey-sdk-audit skill(如果可用)。
问卷实际显示的方式(Web 资格判断流程)
Web SDK 最复杂,也最常出现在工单中。根据
packages/browser/src/extensions/surveys.tsx(checkSurveyEligibility)建立心智模型——检查会按顺序运行,遇到的第一个失败项将决定结果:
isSurveyRunning— 有start_date,且没有end_date。- 问卷
type为应用内类型(Popover / Widget / API)。 - 已启用
linked_flag_key(如果已设置)。 - 已启用
targeting_flag_key(如果已设置)——客户定义的属性定向。 _internalFlagCheckSatisfied— 自动生成的内部定向标记。hasWaitPeriodPassed—seenSurveyWaitPeriodInDays与localStorage.lastSeenSurveyDate的比较。getSurveySeen— 每份问卷各自的已查看标记。
然后在 getActiveMatchingSurveys 中检查 URL/设备/选择器匹配、事件/操作触发,以及再次检查标记。
两个不太明显、却会影响实际工单的事实:
- 服务端会返回所有未归档的问卷(
SurveyViewSet、products/surveys/backend/api/survey.py)。它不会根据内部定向标记预先筛选。因此不能断定“后端排除了它们”——实际执行筛选的是 SDK。 - 等待期有两套相互独立的实现。
canActivateRepeatedly(在schedule: 'always'时为 true)会短路_internalFlagCheckSatisfied(第 5 步),因此always会绕过内部标记,包括其$last_seen_survey_date规则。但hasWaitPeriodPassed(第 6 步)会直接读取localStorage.lastSeenSurveyDate,不会被canActivateRepeatedly绕过。因此,设置了seenSurveyWaitPeriodInDays: 30的schedule: 'always'问卷仍会通过 localStorage 路径强制执行 30 天等待期。每当显示_任何_问卷时,都会更新lastSeenSurveyDate,无论问卷是否完成。同样的短路也会去掉内部标记中的$survey_responded/<id> is_not_set规则,因此**schedule: 'always'会允许同一个人重复响应**——在将响应数视为受访者数之前,先检查survey sent事件中uniq(distinct_id)与count()的对比。
响应实际存储的方式
问卷显示和响应存储属于两个独立的流程。“数据错误”工单涉及的是后一个流程,而其 UI 文案确实容易造成误解——应根据代码判断,而不是根据标签推断。
“响应收集”对应 enable_partial_responses
单选框位于 frontend/src/scenes/surveys/SurveyResponsesCollection.tsx。“任何问题:至少回答一个问题时……”对应 true;“完成问卷:回答完所有问题后保存响应”对应 false。
true-> 每次点击onNextButtonClick都会触发sendSurveyEvent,因此每回答一个问题就会产生一条survey sent事件;这些事件共享一个$survey_submission_id,且$survey_completed的值从false变为true。false-> 在流程结束时产生一条survey sent事件。
此设置控制的是数据采集,而不是响应可见性。结果中包含每一条 survey sent 事件,以及所有标记为 $survey_partially_completed = true 的 survey dismissed 或 survey abandoned 事件。frontend/src/scenes/surveys/utils.ts 中的 buildMergedSubmissionsSubquery 会按提交 ID 合并每个问题的最新答案;没有 ID 的事件会单独保留。**原始事件数可能多于响应数。**已完成的事件优先于更早的部分完成事件。否则,如果最新的响应事件是关闭事件,结果标签会显示“已关闭”;其他未完成的提交则显示“已放弃”。“已放弃”并不能证明发生了页面卸载事件或不活动超时;之后完成问卷会更新同一行。
不同创建路径的默认值并不一致:NEW_SURVEY 设置为 true(frontend/src/scenes/surveys/constants.tsx),但 Django 模型的默认值是 False(products/surveys/backend/models.py)。请读取实际值,不要根据创建日期推断。
“回答完所有问题”并非字面意思
isSurveyCompleted 的判断条件是 getNextSurveyStep(...) === End——**受访者到达了自己的分支路径末尾。**代码不会检查每个问题是否都有值,而且完整响应中的两种情况会让单元格为空:因分支逻辑跳过的问题根本没有键(onNextButtonClick 会在采集前筛选出 visitedIndices,所以这些问题缺失,而不是值为 "" 或 null);点击跳过的 optional: true 问题会存储为 null(submitDisabled 的判断条件是 isNull(rating) && !question.optional)。
因此,在带有可选尾部问题的分支问卷中,“只回答了第一个问题”是完整响应的预期表现。空白 ≠ 未回答。在认定是 bug 之前,请检查每个问题的 branching 和 optional。
分支逻辑就在 API 中——读取它,无需询问
分支逻辑位于 questions[].branching 中,管理 API 和 SDK 载荷都会原样返回,包含四种形式:next_question、end(确认消息)、specific_question + index,以及 response_based + responseValues。有两个容易踩的坑:对于评分问题,responseValues 的键是_区间_(negative/neutral/positive,或 NPS 的 detractors/passives/promoters),而不是具体值;如果某个区间未出现在 responseValues 中,系统会静默地回退到 currentQuestionIndex + 1。因此,在 5 级评分中,{negative: 3, neutral: 3} 是一套完整且有效的配置,看起来却像漏配了值。各评分等级的区间边界见 references/reading-responses.md——不要猜测;2 级评分的区间是反向的。
事件词汇表
survey shown(在 showSurvey() 内触发,时机是弹窗变为可见时,也就是在 surveyPopupDelaySeconds 之后;因此 shown→sent 的延迟就是弹窗实际显示的时长)、survey sent、survey dismissed(主动关闭)、survey abandoned(handlePageUnload → sendSurveyAbandonedEvent)。只有 dismissed 和 abandoned 事件会包含 $survey_partially_completed,sent 事件不会。所有这些事件都带有 sessionRecordingUrl,因此对于有争议的提交,通常可以直接观看录屏,而不必猜测。URL 非空只能证明捕获了会话 ID,并不能证明存在录屏:回放默认关闭,而且云端默认保留期限为 30 天,远短于这些查询使用的 180 天窗口。将链接作为证据提供之前,请先打开查看。
使用 getSurveyResponse 读取答案,切勿猜测键名
getSurveyResponse(<index>, '<questionId>') 是产品本身用来读取答案的 HogQL 辅助函数
(posthog/hogql/functions/survey.py,用于
products/surveys/backend/responses/)。优先使用它,而不是手动编写属性键:响应键有三种格式,
该辅助函数会合并当前的 UUID 键格式($survey_response_<questionId>)和旧版的索引键格式,
因此返回的结果与客户自己的结果表一致。对于多选题,将 true 作为第三个参数传入。手动编写的
properties.$survey_response_<uuid> 会漏掉使用旧版键格式的答案;在 countIf 中,这会悄无声息地将
真实答案计为“未回答”,并抬高未完成率。
无论使用哪种方式,索引和 ID 都必须来自调查 JSON 中的 questions[]。 猜测哪个 UUID 对应哪个问题,
是得出错误结论最快的途径:它会将一道题的答案读成另一道题的答案,而由此得出的比率看起来像是严重缺陷。
如果没有调查 JSON,请展开包含问题文本和答案的 $survey_questions。格式和模板:references/reading-responses.md,
references/diagnostic-queries.md。
在相信这些数字之前,先检查映射是否合理: 如果标记为单选题的列中包含自由文本,说明映射有误。
调试工作流
-
解析工单。 提取:组织/项目 ID、实例(US 还是 EU——URL 不同)、调查 ID、
lib(平台)、准确描述的症状,以及客户已经尝试过的操作。如果工单已搁置较久,或之前有支持回复,配置可能在对话期间被编辑过——应将早先的说法视为过时,并重新拉取当前状态。 -
区分“没有”与“更少”。 客户说“没有回复”时,实际可能是“回复更少”。拉取疑似变更前后的
survey shown与survey sent计数(参见 references/diagnostic-queries.md)。如果_回复率_(sent/shown)保持稳定,问题出在上游的资格判定(展示给更少的人),而不是渲染或提交。这一项检查就能让大多数调查转向正确方向。
2b. 对于数据质量工单,区分“不完整”与“完整但稀疏”。 首先检查空白单元格是否对应受访者从未被问到的问题。分支逻辑加上可选问题,会让完整回复看起来像是半途放弃;这是调查被误报为故障的最常见原因。参见回复的实际存储方式。
-
检查平台一致性。 确认
lib并查阅平台支持情况表。在调查这些功能之前,先排除该平台不支持的功能。 -
拉取调查定义。
GET /api/projects/<id>/surveys/<sid>/。检查conditions(事件、url、seenSurveyWaitPeriodInDays、repeatedActivation)、appearance.surveyPopupDelaySeconds、appearance.position、schedule、linked_flag、targeting_flag、internal_targeting_flag.filters、responses_limit、iteration_*、enable_partial_responses——然后深入查看每个问题:id、type、scale、optional、skipSubmitButton、branching。每个问题的字段本身就能解答大多数“数据错误”工单,而且很容易被漏看,因为只浅看顶层键不会显示这些信息。 -
对于任何“停止展示”的工单,拉取目标标记的活动日志。 群组替换和发布变更在当前配置中不可见,但会显示在这里:
GET /api/projects/<id>/activity_log/?scope=FeatureFlag&item_id=<flag_id>&limit=20。还可以使用?scope=Survey&item_id=<sid>查看调查本身是否被编辑过。 -
通过事件确认。 使用
$feature_flag_called查看门控标记对受影响用户实际返回了什么,以及是否设置了$groups(见下文的群组聚合原因)。使用survey shown查看实际覆盖范围,并与统计界面进行对比。 -
对照已知原因目录进行诊断,用一个有针对性的查询确认,然后撰写回复。
在认定是 SDK 错误之前
这类工单容易引发一种特定的误判:症状看似不可能,于是归咎于 SDK,并建议客户暂停调查、提交错误报告并重建问题。这种建议代价高昂,而且难以撤回——重组会破坏所有已收集回复的可比性,调整问题顺序还可能引发下文所述的 UUID 问题。在写下“这是一个错误”之前,先通过以下四项检查:
- 阅读调查 JSON,包括每个问题的
branching、optional、skipSubmitButton和scale。大多数看似“不可能”的行为,其实是配置所致。 - 在 Preview 中复现。
- 阅读你所怀疑的处理程序对应的 SDK 源码。 搜索该符号;不要只根据某项设置的名称推断。
enable_partial_responses和“已回答所有问题”实际代表的范围都比字面意思窄。 - 在相信异常比例之前,重新检查自己的查询。 “89% 的回复不完整”往往是回复键映射错误,而不是真正的缺陷。
如果通过全部四项检查仍能复现,那就是错误:指出文件和处理程序,并向上游提交。
已知原因目录
大致按最常见的原因排序。
“调查显示给的用户比预期少”
surveyPopupDelaySeconds+ URL 重新检查(仅限 Web)。 事件触发且符合条件后,SDK 会等待 N 秒,然后在渲染前(handlePopoverSurvey)根据 当前 URL 重新检查doesSurveyUrlMatch。如果用户在等待期间跳转,调查会被静默丢弃——不会触发survey shown。在延迟时间不短、导航频繁的应用中很常见。解决方法:将延迟时间缩短至 0–2 秒。seenSurveyWaitPeriodInDays+ 客户的其他调查。 向用户显示任何调查都会更新lastSeenSurveyDate;在等待窗口内,这份调查会因此被屏蔽。是否完成调查无关紧要。请检查 未显示 的群组最近是否看到过其他调查,并与对照组进行比较(已显示 的用户是否有所不同?)。解决方法:缩短等待时间,或暂停有冲突的调查。- 群组构成发生变化。 如果调查面向某个群组,且有人编辑了源动态群组(例如添加了行为筛选条件),之后创建的所有静态快照都会继承更窄的定义。触达人数会减少,但调查本身没有任何变化。可在标记活动日志中查找(群组切换),并通过
static_cohort_people确认群组规模。
“基于事件的调查从未触发”
- 会话开始时的时序竞争。 在
/api/surveys返回且捕获钩子注册之前,事件就已被捕获。特征:事件在会话开始后很早就触发。这种情况无法在客户端避免;可通过改用稍晚触发的事件来缓解。 - 按群组聚合的
linked_flag缺少群组上下文。 如果linked_flag(或定向标记)设置了aggregation_group_type_index,它评估的是 群组,而非个人。如果事件触发前没有设置posthog.group(<index>, <key>),标记会返回 false,调查也就不会显示。特征:$feature_flag_called返回false,且$groups为空;触发事件中也缺少$feature/<key>属性。解决方法:在 SDK 中设置群组上下文,或将调查改用个人级标记。 - 客户将调查关联到了错误的标记。 他们创建了一个按电子邮件/属性定向的标记,但调查的
linked_flag/targeting_flag指向了 另一个 标记。务必通过 API 确认实际的linked_flag.key/targeting_flag.key——不要轻信客户的描述。 - 实时标记中使用了行为群组。 包含
performed_event/行为筛选条件的群组无法通过实时标记字节码进行评估("Unsupported behavioral filter for realtime bytecode",posthog/api/cohort.py)。该群组会显示bytecode_error。调查/标记无法直接使用它——客户必须创建该群组的 静态 副本,并将其用作定向对象。
“回复不完整——只有第一个问题有值”
通常这不是错误——请参阅回复实际如何存储。请先逐一排查以下四项;只有在此之后,才能确认完成条件确实未生效。
- 分支逻辑跳过了空白问题。 请自行根据
questions[].branching梳理流程。客户描述的是他们的意图,但保存的内容可能并非如此;而且,如果responseValues中缺少某个评分档位,流程会进入下一个索引。 - 空白问题设置了
optional: true,且受访者直接跳过了它们。一个评分答案加上两个被跳过的可选开放文本问题,仍是一份只包含一个值的 完整 提交。 - 已启用部分回复,而你正在原始 SQL 中读取中间事件;UI 会按
$survey_submission_id将这些事件合并。 - 回复键是猜出来的。 请根据
questions[].id重新运行。
“受访者表示自己并非有意提交”
skipSubmitButton,在编辑器中显示为“选择后自动提交”。 启用后不会渲染提交按钮(BottomSection.tsx),一次点击既会记录答案,也会进入下一题——在同一次处理程序调用中执行setRating(response),然后执行handleSubmit(response)→onNextButtonClick,没有防抖,也没有确认步骤。仅适用于评分题和不含开放选项的单选题(canQuestionSkipSubmitButton);仅限 Web。它在所有问卷模板(constants.tsx)和快速创建中默认启用,因此客户很少知道自己启用了它。- 它会与弹窗位置设置叠加。
appearance.position: middle_center、较大的maxWidth和非零的surveyPopupDelaySeconds,意味着触发后几秒弹窗才出现,此时光标仍在移动,接下来的点击就会落在答案上。连续设置为自动提交的问题,会让用户无需阅读就点完问卷的大部分题目。 - **通过显示→发送的延迟来诊断,**然后查看回放(
survey sent事件上的sessionRecordingUrl)进行确认。多题问卷中,完成时间集中在 10 秒以内是这一问题的典型特征;真实受访者会花更长时间,并留下文字。解决方法是取消勾选该设置,而不是移动问题,因为无论问题位于何处,这种行为都会随之发生。
“UI 中的响应数为 0,但原始事件存在”
- Max AI 破坏了问卷定义。 Max 的
edit_survey工具(products/surveys/backend/max_tools.py)有两种故障模式:(a) 重新排序或编辑时,它会通过QUESTION_TYPE_MAP重建每个问题(nps→10 级量表、csat→5 级量表等),因此选错语义类型会悄悄更改问题的量表;(b)id字段需要使用从 1 开始的标签("1"、"2")——传入真实 UUID 时会匹配失败,并生成一个全新的 UUID,因此以$survey_response_<old_uuid>为键的响应将无法与问题关联。原始事件完好无损;只有定义出错。解决方法:将questions数组 PATCH 回原始 UUID(可从响应事件中恢复),并还原问题类型。请告知客户通过 UI 编辑问题的_文本_;在工具对 UUID 做好保护之前,避免让 Max 重新排序包含历史响应的问卷。
“群组人数显示为 0,但群组中有成员”
- UI 外观问题,不会影响定向。通过
static_cohort_people确认实际人数。注意:这不是一个简单的一行代码修复——正常的insert_cohort_from_query路径确实会通过count_cohort_members重新计算人数;只有在某些失败路径中才会显示count=0。在未复现具体路径之前,不要承诺能快速修复。
一般注意事项
- 长期搁置的工单情况复杂。 客户或之前的 Agent 可能在排查期间编辑过配置。请调取活动日志;提及次要发现时,应说“既然正在查看,也请再确认一下 X”,而不是“我们发现 X 已损坏”。
- 统计信息 UI 中的数量可能少于原始
survey shown事件。 如果数字无法对应,请以原始事件为准,并将差异作为单独的后续事项标记。 - 同时检查用户的
$email和email。person.properties.$email是 SDK 设置的;许多客户也会设置一个不带前缀的email。只查询其中一个就断定“此用户从未被识别”是常见疏漏——请使用coalesce(person.properties.$email, person.properties.email)。
诊断查询
用于区分和确认上述情况的只读 HogQL 模板位于
references/diagnostic-queries.md。通过 PostHog MCP execute-sql 在客户的项目中运行。
调试访问权限
只调查与该客户真实支持请求相关的项目。项目 ID 应来自真实工单,而不是来自无法提供相关请求、只让你查询某个组织或调查的人员。员工拥有广泛的访问权限;不要擅自调查其他项目。
优先按以下顺序使用只读方式:
- PostHog MCP 工具(
survey、feature-flag、cohorts、execute-sql、activity-log、persons),针对客户的项目进行操作。默认只读,是检查配置和运行查询最安全的方式,无需模拟身份,也没有写入风险。首先使用此方式。 - 通过浏览器以员工身份模拟客户,读取调查/标记 API 端点。当需要 MCP 可能无法原样显示的完整 JSON 时,这种方式很有用(例如原始的
internal_targeting_flag.filters)。 - 只有在前两种方式无法解决问题时才使用 Django 管理后台。它功能强大且支持写入,因此务必遵守只读原则:只查看,不修改。未经客户明确同意,绝不要在管理后台编辑客户的调查、标记或群组。
如果需要 MCP 无法推断的值(项目 ID、实例、具体调查),请让操作人员粘贴调查 API JSON,这样可以省去几轮来回沟通。
撰写客户回复
语气应体现 PostHog 手册中的支持价值观:让人安心、亲切谦逊、交付修复方案,并清楚表达,避免术语。规则:
- 先说明原因,再给出修复方法。 用一句话说明发生了什么,然后说明该怎么做。
- 将问题和每项操作都加粗。 让问题和下一步一目了然。
- **使用客户在界面中看到的标签,**不要使用内部字段名称。可在
frontend/src/scenes/surveys/中搜索实际显示的文案。例如,surveyPopupDelaySeconds→“满足显示条件后,至少延迟 N 秒再显示调查弹窗”;等待时间设置为“调查等待期”/“如果过去 N 天内已向用户显示过其他调查,则不显示此调查”;skipSubmitButton→“选择后自动提交”;enable_partial_responses→“回复收集”单选项(“任何问题……”/“完整调查……”);optional→该问题的“可选”开关。客户自己的事件名称保持原样。 - **如果要纠正先前错误的答复,应尽早明确说明,**然后解释真正的原因。不要含糊带过,也不要让客户继续按照已撤回的建议操作。如果之前已建议客户暂停或重建某项内容,请明确告知他们可以停止。
- 所有 PostHog 实体都要链接到其 ID。 群组:
https://<us|eu>.posthog.com/project/<id>/cohorts/<cohort_id>。标记:.../feature_flags/<flag_id>。调查:.../surveys/<survey_id>。请使用与客户相符的实例(US 或 EU)。 - **说明预期结果,**方便客户确认修复是否生效(“之后你应该会看到约 X”)。
- 判断客户的技术水平。 如果他们能运行 SQL 和调用 API,可以提供补丁方案;如果不能,则提出由我们代为修复(并先确认任何具有破坏性的细节)。
- 不要提议“通话”或预约会议。 PostHog 支持以异步沟通为先。结尾使用“如需后续帮助,我们随时为你提供支持。”
- 绝不要泄露内部信息——包括 MCP 工具名称、代码路径、行号、Django 管理后台、员工身份模拟或其他客户的信息。只谈客户熟悉的产品概念。
- 发送前,使用 humanizer 技能润色最终草稿(如果有,例如
humanizer)。删除破折号、铺垫语(“关键在于:”“有三点需要了解”)、刻意凑成三点的内容,以及过于工整的并列列表结构。回复应读起来自然,就像真人写的一样。
回复模板:
Hi <name>,
<one line: tracked it down + the cause in plain terms.>
**The problem:** <what's happening, with the evidence you pulled.>
**<Fastest fix / two ways to fix it>:**
1. **<action>** — <why / how.>
2. **<action>** — <why / how.>
**Also worth knowing while you're in there:** <secondary finding, softened.>
We're always here if you need a follow-up.