面试 Q&A — 真机端到端集成调试复盘#
主题:一次”打开所有开关 + 手机飞书一键审批真机演示”的过程,连续暴露并修掉 5 个单测全绿也 照样漏的 bug。这份文档讲的不是某个功能,而是一个工程观点:离线单测覆盖不了集成边界, 真机端到端跑一遍不可替代。相关提交
fe59e10(3 处)/b88bcb4(2 处)/fa7e53b(1 处)。 目标链路:诊断→处置提案→审批→手机飞书点同意→worker 执行 container_restart→回归验证。
一、核心观点#
Q1: 你这套系统单测 374 passed 全绿,为什么还要真机端到端跑?#
因为这 5 个 bug 没有一个能被离线单测抓到——它们全在”进程边界 / 序列化边界 / 第三方 SDK 契约 / 部署编排”这些单测天然 mock 掉的地方:
| # | Bug | 位置 | 为什么单测漏 |
|---|---|---|---|
| 1 | run_all.sh 漏起 remediation_worker | 部署脚本 | 单测不测启动编排,进程”在不在”不在测试范围 |
| 2 | jsonb 列回读成 str 后 dict(str) 崩 | asyncpg 序列化 | 单测传 dict fixture,不经过真实 DB 的 codec 行为 |
| 3 | committed 被误判 tool_error | MCP 返回序列化 | 单测直接传 dict record,不经过 langchain-mcp-adapters 的内容块封装 |
| 4 | 飞书长连接跨线程 event-loop 崩 | asyncio × threading × 第三方 | 单测只测纯 handle_card_action,不起真 WS 客户端 |
| 5 | 卡片回调 handler 用错类 → 飞书 200671 | lark SDK 内部契约 | 单测 mock apply_fn,不触发真实 lark 事件分发 |
结论:单测保证”我的逻辑对”,真机保证”我和外部世界的契约对”。两类测试测的是不同东西, 后者只能靠端到端。这也是我坚持真跑一遍的原因——事实证明每一环都有坑。
二、五个 bug 逐个复盘#
Q2: Bug#1——审批批了,容器却没重启,怎么定位的?#
现象:审批中心 POST /decide 返回 200、状态变 approved,但容器 StartedAt 没变、worker 日志
无执行痕迹。排查链:查进程 ps aux | grep worker → 发现只有 3 个 diagnosis_worker,没有
remediation_worker。看 run_all.sh:它只 for i in seq WORKERS 起诊断 worker,从没起过
消费已批准处置的 app.remediation_worker。
这是部署编排缺陷:解耦处置执行链路缺了消费端进程,批准的审批堆在 approved 无人认领。
修复:run_all.sh 补起 remediation_worker(SKIP_REMEDIATION_WORKER=1 可跳过),stop_all
经 .run/*.pid 自动停。教训:一个后台消费者,代码写得再对,没被启动脚本拉起就等于不存在。
Q3: Bug#2——手动起 worker 后,它疯狂报 dictionary update sequence element #0 has length 1?#
dict("{}") 的典型报错。定位到 remediation_worker._proposal_from_row 的
dict(row.get("pre_state") or {}):pre_state 是 jsonb 列,asyncpg 在这条连接上把 jsonb
返回成了 JSON 字符串 "{}",dict("{}") 把字符串当 key-value 序列迭代 → 每个字符长度 1 → 崩。
为什么潜伏这么久?之前 e2e 走的是 inline 路径(proposal 是内存 dict,不经过
_proposal_from_row);这次走解耦 worker 路径才第一次真的从 DB 读回 proposal。
根因是 _row_to_dict 只 json 解析了 tool_args,漏了 pre_state / recovery_plan。
修复:把所有 jsonb 列统一纳入解析。教训:同一份数据”内存直传”和”落库回读”是两条码路,
只测前者会漏掉序列化往返的坑。
Q4: Bug#3(最隐蔽)——容器真被重启了,事务却报 tool_error,这是什么情况?#
“假失败”:docker MCP 侧日志明明是 事务完成 → committed、容器 StartedAt 也变了,但
app 侧 run_transaction_tool 报 status=tool_error succeeded=False。
我一开始猜了两次都错(以为是 JSON 双引号问题、又以为是单引号 Python repr),最后加一行
logger.warning(repr(record)) 打出真身才看清:langchain-mcp-adapters 返回的不是 dict、
也不是裸 JSON 字符串,而是内容块列表:
[{'type': 'text', 'text': '{"status":"committed","succeeded":true,...}'}]python真正的事务 JSON 藏在 record[0]["text"] 里。parse_transaction_record 只 isinstance(record, dict)
判断,非 dict 一律 tool_error。修复:先从内容块抽文本 → json.loads(兜底 ast.literal_eval)。
这个 bug 最危险:不是崩溃,是语义反转——成功被报成失败。在闭环里这会触发不必要的人工 升级,甚至在多步 Saga 里走错补偿分支。教训:① 猜三次不如打印一次真身;② 跨进程/协议的返回值 永远要验证真实格式,不要假设。补了 5 个回归用例覆盖 dict / JSON串 / repr串 / 内容块列表 / 垃圾串。
Q5: Bug#4——飞书长连接为什么在 daemon 线程里报 this event loop is already running?#
lark_oapi.ws.client 在模块级写了 loop = asyncio.get_event_loop()——这行在模块首次 import
时执行。我原来在主线程(FastAPI lifespan,async 上下文)first-import 了它,于是这个全局 loop
被绑成主线程正在跑的 loop;随后 daemon 线程调 start() 内部 loop.run_until_complete(...)
就撞上”主 loop 已在运行”。
修复:把 lark 的 import + 构造 + start 全部搬进 daemon 线程,且 import 前先给该线程
asyncio.new_event_loop() + set_event_loop,让那个模块级 get_event_loop() 拿到线程自己的 loop;
卡片回调再用 run_coroutine_threadsafe 调度回主 loop 执行 apply_decision。附带修了多 worker
问题:4 个 uvicorn worker 会各起一条长连接,用 Redis SETNX owner 锁 + 续期只留一条。
教训:第三方库在模块级捕获全局状态(event loop)是隐雷,import 时机决定行为; asyncio 跨线程要非常清楚”哪个协程跑在哪个 loop 上”。
Q6: Bug#5——手机点”同意”报 200671,但后端审批还是 pending,怎么回事?#
飞书前端提示 Something went wrong (200671)。查 app 日志抓到真因:
'CardActionHandler' object has no attribute '_do_without_validation'plaintext回调其实到达了长连接,但卡在 lark SDK 内部:ws.Client 分发事件时调
event_handler._do_without_validation(...),而我传的 CardActionHandler 没有这个方法——只有
EventDispatcherHandler 有。我用错了 handler 类。
修复:卡片回调必须走 EventDispatcherHandler.builder().register_p2_card_action_trigger(cb),
回调返回 P2CardActionTriggerResponse(带 toast)。改完手机点同意一路贯通:
decided_by=feishu:ou_... → worker 执行 → 容器真 restart → service_health 门禁通过 → done。
过程中还踩了个认知坑:第一次”看似批准了但后端 pending”,是因为飞书群里堆了多张历史卡,
点到了旧卡(对应审批已处理)→ 返回 already_handled。加一行”收到卡片回调 req_id/open_id”
诊断日志 + 清掉旧 pending 卡只留一张,立刻看清。教训:多实例/历史消息场景,日志里必须能
回答”这次到底处理的是哪一条”。
三、方法论沉淀#
Q7: 这轮下来,你对测试策略的认知是什么?#
分层,各司其职,不互相替代:
- 单元测试(374 passed,全离线):证明”我的逻辑对”——门禁语义、消毒、幂等、状态机。快、 可 CI、覆盖分支;但对外部契约是盲区。
- 集成/真机端到端:证明”我和外部世界的契约对”——DB 序列化、MCP 返回格式、第三方 SDK、 进程编排、真实点击。慢、难自动化,但这 5 个 bug 只有它能抓。
我的做法是离线单测守逻辑正确性 + 关键路径真机跑一遍守契约,两者都做。面试常问”你怎么保证 质量”,我的答案不是”我写了很多单测”,而是”我知道单测的盲区在哪,并用真机端到端补上了”。
Q8: 5 个 bug 里,如果只能记住一条经验,是哪条?#
警惕”假成功/假失败”(Bug#3)。崩溃是好 bug——它会响、会被发现。最危险的是语义反转: 操作真的成功了却被上报成失败(或反过来)。它不崩、日志”看起来正常”、单测也绿,却会让上层 逻辑(重试、补偿、升级、闭环判定)走进错误分支。对这类系统,任何跨边界的返回值都要验证真实 格式、断言真实语义,而不是相信”它应该返回什么”。
Q9: 调试手法上有什么可复用的?#
三条,这轮都用上了:
- 猜三次不如打印一次真身:Bug#3 我猜了 JSON、猜了 repr 都错,一行
repr(record)定位; - 顺着状态机的”断点”倒查:审批 approved 但容器没变 → 断点在”approved→executing”之间 → 查执行者进程 → Bug#1;
- 让日志能回答 who/which/what:Bug#5 的旧卡混淆,靠”收到回调 req_id/open_id/结果”一行日志 秒清。可观测性不是上线才需要,调试阶段就是第一用户。
四、设计取舍与反思#
Q10: 这些 bug 反映出架构上什么可以更好?#
诚实说几条:
- 契约测试缺位:MCP 工具返回格式(内容块列表)、asyncpg jsonb 行为,都应该有一层薄的 “契约测试”——用真实依赖跑最小往返,而不是等真机演示才发现。这是这套项目当前最大的测试缺口;
- 启动编排没有自检:
run_all.sh起完不校验”该在的进程都在”。可以加一个 readiness 自检(remediation_worker 心跳、feishu WS 连接状态)暴露到/health; - “假失败”需要告警:
tool_error与”MCP 侧 committed”不一致本应触发一条一致性告警, 而不是静静落库。可观测的 stats 里应该有”app 侧状态 vs 事务侧状态不一致计数”。
这些不是”没做完”,是真机跑过之后才看清的下一步——比空谈”未来工作”具体得多。