面试 Q&A — 多智能体编排#
覆盖:LangGraph 状态机、Skill-first 路由、Planner-Executor-Replanner 循环、 Fast/Deep 双模式、并行工具执行、预算控制、Skill 重路由。
一、整体架构#
Q1: 举个具体例子,一条告警从进来到出诊断报告,Agent 是怎么编排的?#
场景:Alertmanager 推了一条 HighMemoryUsage 告警,redis-prod-01 内存 99%。warning 级走 fast 模式诊断,看 Agent 怎么一步步推理到根因。
四个节点依次流转:
-
SkillRouter:LLM 看到告警里有
redis、memory,从 Skill 菜单中选中redis_ops(置信度 0.92)。同时查了 LLM Wiki——上周同类告警用redis_ops成功过,进一步确认选择。选中后,Executor 只能看到 redis_ops 允许的 5 个工具(Redis + System),而不是全部 20+ 个。LLM 调用失败时,关键词兜底规则 query 含 “Redis” →redis_ops。 -
Planner:注入 redis_ops 的 Playbook(标准操作流程),LLM 分解出 4 步计划:查内存指标 → 检查慢查询和大 key → 查 maxmemory-policy 配置 → 搜知识库找 SOP。
-
Executor × 4 轮:逐步执行,每步可能多轮 LLM↔工具交互——
- Step 1: 调
get_system_metrics→ 内存 99%;再调get_redis_info→ used_memory 12GB / maxmemory 12GB - Step 2: 调
get_redis_slowlog→ 发现大量KEYS *操作 - Step 3: 调
get_redis_config→ maxmemory-policy=noeviction - Step 4: 调
search_knowledge_base→ 命中 SOP “Redis OOM → 改 allkeys-lru + 排查大 key”
每步之间 Replanner 评估:plan 还有步、上一步正常 → fast-path 直接继续(跳过 LLM 调用省 2-3 秒)。
- Step 1: 调
-
Reporter(deepseek-v4-pro):汇总所有证据,生成结构化报告——根因 “maxmemory-policy=noeviction 导致内存打满无法淘汰”,建议 “改为 allkeys-lru + 排查 KEYS * 调用来源”。
| 节点 | 模型 | 耗时 | 产出 |
|---|---|---|---|
| SkillRouter | deepseek-v4-flash | ~2s | 选中 redis_ops |
| Planner | deepseek-v4-flash | ~3s | 4 步计划 |
| Executor × 4 | deepseek-v4-flash | ~30s | 4 条证据链 |
| Reporter | deepseek-v4-pro | ~5s | 最终诊断报告 |
总计 ~7 次 LLM 调用,~20K token,¥0.04,45 秒完成。 如果是 critical 级会走 deep 模式——4 个专家 Agent(Log/Metric/Infra/Runbook)并行取证,再由 RCA Judge 裁决根因,耗时 2-5 分钟但覆盖更全。
Q2: 你的 Agent 编排流程是什么?#
一个 LangGraph 状态机,4 个节点循环:
[START] → SkillRouter → Planner → Executor ⇄ Replanner → [END]plaintext- SkillRouter:LLM 从 Skill 菜单中选一个最匹配的 Skill(如
redis_ops、disk_pressure) - Planner:基于选中 Skill 的 Playbook,分解出 3-6 步执行计划
- Executor:执行 plan[0]——调 LLM + MCP 工具取证
- Replanner:评估进展,决定 继续 / 结束出报告 / 换 Skill 重来
Executor ⇄ Replanner 形成循环,最多迭代 5 次(agent_max_steps),防止 LLM 走入死循环。
Q3: 为什么用 LangGraph 而不是自己写循环?#
LangGraph 提供了三个自己写难以做好的东西:
- 状态管理:
PlanExecuteState用 TypedDict +Annotated[List, operator.add]做累加字段,past_steps 自动追加不用手动管理。 - 条件边:Replanner 出来有三个走向(executor / planner / END),用
add_conditional_edges声明式定义,比 if-else 链更清晰。 - 流式输出:
graph.astream()自动把每个节点的输出 yield 出来,前端 SSE 直接对接。
如果重来会不会不用 LangGraph?说实话,对 fast 模式可能不用——就是个循环。但 deep 模式有 4 个子 Agent 并行 fan-out + join barrier,LangGraph 的 StateGraph 处理并行节点很方便。
Q4: Fast 和 Deep 模式有什么区别?#
| 维度 | Fast | Deep |
|---|---|---|
| 图结构 | 单链循环 (Router→Plan→Exec→Replan) | 8 节点 DAG (fan-out + fan-in) |
| 子 Agent | 无 | 4 个专家 (Log/Metric/Infra/Runbook) |
| LLM 调用 | ~5-8 次 | ~15-20 次 |
| 耗时 | 30s-2min | 2-5min |
| 触发条件 | 默认 / warning 级 / 单域告警 | critical/page/p0 级、告警风暴(≥10 条)、跨故障域,或 fast 跑完后运行时升级 |
Deep 模式的核心是 并行取证 + RCA 裁决:4 个专家 Agent 各自用不同工具(日志检索、指标查询、基础设施探测、知识库 SOP)并行收集证据,汇总后由 RCA Judge(LLM)一次性排名定根因。
Q5: Fast/Deep 到底怎么切?为什么不用 LLM 自评?(高频追问)#
切换分两层,各管一段、职责不同:
- L0 入口预判(webhook
_diagnosis_mode_for,不调 LLM):告警一进来,用便宜的告警特征直接判”明显该上重武器的”跳过 fast。 - L1 运行时升级(
escalation.py,fast 跑完后):用 fast 执行流留下的客观行为轨迹兜住”看着简单实则不简单”的长尾。
L0——三条便宜硬信号(微秒级、纯 label 判断):
| 信号 | 判据 | 为什么直达 deep |
|---|---|---|
| 严重度 | severity ∈ {critical/page/p0} | 影响面大,不值得先赌 30s fast |
| 告警风暴 | 单次 payload 条数 ≥ alert_storm_deep_threshold(10) | 量大,单链查不过来 |
| 跨故障域 | 一组 firing 告警覆盖的故障域数 ≥ alert_cross_domain_deep_threshold(2) | 因果不在单一域内,正是 deep 并行取证 + CorrelationContext 的场景 |
跨域判断的关键:入口要秒回、不能调 LLM,所以用不了 skill_router(那是 fast 进 worker 后才用 LLM 挑域)。改用一张便宜的 alertname→故障域 静态查表(fault_domain.py)——拿一组告警的 alertname 查表、去重、数域个数 ≥2 即跨域。表还优先吃告警自带的 domain label(告警自描述则平台零维护),命不中才回落关键字匹配。
L1——四条运行时信号(命中任一即升级,OR 关系):
| 信号 | 来源 | 含义 |
|---|---|---|
hit_max_iters | transitions 里有 EXECUTOR_MAX_STEPS/REPLANNER_MAX_STEPS_FORCE | 单链没查完,被强制收尾 |
router_missed | transitions 里有 ROUTER_FALLBACK_GENERIC/ROUTER_LLM_FAILED | 没匹配到专门剧本,单链缺方向 |
rerouted | reroute_count > 0 | fast 中途改选故障域,选域反复不稳 |
low_confidence | skill_confidence < 阈值(0.5) | Router 选域没把握,单链可能走错方向 |
# escalation.py — 命中任一即升级,按"从最确凿到较弱"取首个原因(保证单一可归因)
def should_escalate_to_deep(signals, *, min_skill_confidence=0.0):
# 两道护栏:非 OnCall 输入 / 没产出报告(跑挂或提前终止) → 无从"深化",不升级
if signals.is_out_of_scope or not signals.has_report:
return EscalationDecision(False, "", summary)
for hit, reason in [
(signals.hit_max_iters, "调查未完成(打满迭代上限被强制终止)"),
(signals.router_missed, "未匹配到专门排障剧本(Router 回退 generic),单链缺方向"),
(signals.rerouted, "fast 中途改选故障域,选域反复不稳"),
(signals.is_low_confidence(min_skill_confidence), "Router 选域置信度偏低"),
]:
if hit:
return EscalationDecision(True, reason, summary)
return EscalationDecision(False, "", summary)python为什么不用 LLM 自评:早期试过让 LLM 在 fast 结束时自评”你觉得结论可靠吗?1-10 分”,结果它经常给自己打 8-9 分但结论是错的。LLM 的自评置信度和实际准确率之间相关性很弱(calibration gap)。所有升级信号都取自执行流里已有的结构痕迹(transition 常量、state 字段),客观、可复现,不用管 LLM 自己怎么”感觉”。
演进笔记(面试可以主动提,体现权衡判断):L1 最早是”步数打满 + 3 个软信号累积 ≥2”的雷达式判断,覆盖面广但不可归因——一次升级到底是步数超了还是软信号堆够 2 个,要拆 4 个维度才说得清。第一步先收敛成只看步数这一条,换来了单一确定性;但代价是丢了”步数没打满、但压根没选对域/根本没查到方向”这类场景的兜底。这一版是第二步:在保住”单一可归因原因”这个核心约束的前提下把覆盖面扩回去——关键区别是从”软信号累积”改成了”硬信号 OR + 排序取首因”,每条信号都独立映射到一个具体可观测的运行时事实(某个 transition / 某个 state 字段),所以升级永远只输出一个能追溯的原因,而不是”凑够 2 分”。再加两道护栏(out-of-scope / 无报告)堵住”把非故障或跑挂的任务误升级”的坑。这条弧线(多→收敛→再受约束地扩展)本身就是面试点:不是加信号越多越好,而是先把可解释性这条约束立住,再在约束内扩覆盖。
升级后怎么衔接:fast 的 past_steps(已收集的证据)传给 deep 模式,不丢弃已做的工作。升级事件会通过 SSE 流推给前端,显示升级原因和信号摘要(signals_summary 带上所有信号的取值,便于事后复盘”为什么升 / 为什么没升”)。
二、Skill-first 路由#
Q6: 为什么要先选 Skill 再做 Plan,不直接让 LLM 规划?#
Skill = 工具白名单 + 领域 Playbook。
不选 Skill 的后果:LLM 面对 20+ 个 MCP 工具,不知道该用哪些。实测它会随机尝试不相关的工具(比如排查 Redis 时去调 Docker 工具),浪费步骤和 token。
选了 Skill 后:
- 工具收窄:Planner 只看到该 Skill 允许的工具(如
redis_ops只暴露 Redis + System 工具) - Playbook 引导:Planner 的 prompt 里注入了该 Skill 的标准操作流程,分解出的步骤更靠谱
- 可量化:Skill Router 的选择准确率可以单独评估
Q7: Skill Router 怎么选的?准确率多少?#
LLM 结构化输出 SkillChoice:
class SkillChoice(BaseModel):
is_oncall: bool # 是否是运维诊断范畴
skill_name: str # 选中的 Skill
confidence: float # 置信度 0-1
reason: str # 选择理由pythonRouter 还会查 LLM Wiki(历史诊断经验),如果之前同类告警用某个 Skill 成功过,会作为 lessons 注入到 prompt 里影响选择。
如果 LLM 调用失败,兜底走 关键词规则:query 里有 “Redis” → redis_ops,有 “磁盘” → disk_pressure。
Q8: 面试追问:Router 选错了怎么办?#
有 Skill 重路由机制。Replanner 在评估进展时,如果发现当前 Skill 的工具不适用(比如 Router 选了 redis_ops 但实际是数据库问题),可以提议换 Skill。
但不是 LLM 说换就换,有 三层门禁:
# replanner.py — _validate_reroute()
# 1. 至少执行了 2 步 (证据不足不让换)
# 2. 重路由次数 < max_reroutes (默认 1 次,防止来回跳)
# 3. 新 Skill ≠ 当前 Skill (防自循环)
# 4. 新 Skill 不在 tried_skills 黑名单 (防环形)
# 5. 新 Skill 存在于 SkillRegistrypython重路由触发后,graph 回到 Planner 节点重新规划,但 past_steps 保留(之前收集的证据不丢)。
量化效果:引入 Skill 路由前,Planner 面对 30+ 个 MCP 工具,LLM 经常尝试不相关的工具(排查 Redis 时调 Docker exec,排查磁盘时调 Redis info)。引入后每个 Skill 平均暴露 8-10 个工具(白名单),误调用率下降约 60%。衡量方式:从 tool_calls 表里统计”工具调用后结果未被后续步骤引用”的比例,作为误调用的代理指标。
Q8.5: 那 60% 这个数字里,Playbook 占了多少?(高频追问:白名单和 Playbook 到底谁在起作用)#
这两个是正交的机制,60% 那个数字只对应白名单,不能把 Playbook 的贡献也算进去。 拆开说:
| 机制 | 解决的问题 | 失效时的症状 | 对应指标 |
|---|---|---|---|
| 工具白名单 | LLM 该调哪个工具 | 调错域的工具(排查 Redis 时调 Docker exec) | 误调用率(已测,↓60%) |
Playbook(skill.playbook 注入 Planner) | 面对同一个工具集,该按什么顺序查 | 查漏关键项(没查 maxmemory-policy 就下结论)、顺序低效(先搜知识库再查指标,来回折返) | 有效步骤占比 / 收敛步数(未单独测) |
白名单管的是”选择空间”,Playbook 管的是”选对了工具之后,先查什么、后查什么”——即使只有 5 个工具可选,没有 Playbook 时 LLM 仍然可能现场瞎试顺序(先查配置又回头查指标),一样浪费步骤。这也是为什么 planner.py 里把 skill.playbook 作为 prompt 种子注入,而不是只给工具白名单就完事。
如果被追问”Playbook 具体降低了多少步数/提升了多少效率”,诚实的答法是:这块目前没有像误调用率那样做过专门的 A/B 拆分实验,60% 是白名单去噪的效果,不能张冠李戴。要拿到 Playbook 单独的量化数据,需要做的实验是:
- 有效步骤占比:
tool_calls表里”结果被下游步骤或最终报告引用”的比例,按 Skill 维度统计,对比「白名单+Playbook」vs「只给白名单,Playbook 置空」两组 - 收敛步数:从 SkillRouter 选中到 Replanner 判定”证据充分可以出报告”经过的 iteration 数,同样做 A/B(控制变量:同一批历史告警重放,只切换是否注入 Playbook)
这个实验目前还没跑,属于诚实的边界——讲的时候把”已验证的 60%“和”逻辑上应该成立但未量化的 Playbook 收益”分开说,别混成一个数字,经不起拆解追问。
三、Planner-Executor-Replanner#
Q9: Planner 怎么分解任务?#
Planner 把用户问题 + Skill Playbook 交给 LLM,要求输出 Plan 结构体(3-6 步):
# planner.py
messages = harness.build_planner_messages(
user_input=user_input,
skill_playbook=skill.playbook, # Skill 自带的标准操作流程
)
plan = await ainvoke_structured(llm, schema_cls=Plan, messages=messages)pythonPlaybook 是关键——它不是让 LLM 从零开始想”怎么排查 Redis”,而是给它一个模板:“第一步查内存、第二步看慢查询、第三步检查持久化配置”,LLM 根据实际告警内容微调这个模板。
如果 LLM 返回空计划或调用失败,兜底返回通用两步计划:["查询知识库", "汇总现有信息给出结论"]。
Q10: Executor 怎么执行单步?#
# executor.py — execute_node()
current_step = plan[0]
result = await run_parallel_agent(
llm=llm,
tools=skill_filtered_tools, # 只有当前 Skill 允许的工具
system_prompt=executor_system_prompt,
inputs={"messages": [("user", task_prompt)]},
max_iters=4, # 单步最多 4 轮 LLM↔工具交互
max_parallel=6, # 单批最多 6 个只读工具并行
decisions=decisions, # 工具权限 (allow/ask/deny)
)python一步可能包含多轮 LLM↔工具交互。比如 “检查 Redis 内存使用”这一步,LLM 可能先调 get_system_metrics,看到内存高后再调 run_command 查 Redis info,然后再调 search_knowledge_base 找 SOP。
Q11: Replanner 有哪些决策路径?#
Replanner
│
┌─────────────┼─────────────┐
▼ ▼ ▼
结束出报告 继续执行 换 Skill
(response 填充) (plan 更新) (pending_reroute=True)
│ │ │
▼ ▼ ▼
[END] Executor Plannerplaintext在调 LLM 之前,Harness 会先做 Pre-LLM 快速判断:
# agent_harness.py — evaluate_replanner_pre_llm()
if iteration >= max_steps:
return "force_report" # 步数到了,强制出报告
if has_repeated_steps(past_steps):
return "force_report" # 检测到重复步骤(死循环),强制中止
if plan_remaining >= 2 and last_step_ok:
return "continue_fast_path" # 计划还有步,上一步没报错,跳过 LLM 直接继续pythonFast-path 是性能优化——如果 plan 还有 3 步、上一步执行正常,没必要花 2-3 秒调 LLM 问”要不要继续”,直接继续就行。
Q12: 怎么防止 Agent 无限循环?#
四层防御:
| 层 | 机制 | 配置 |
|---|---|---|
| 步数硬限 | iteration > max_agent_steps → 强制出报告 | 默认 5 步 |
| 重复检测 | 最近 3 步指纹相同 → 强制中止 | Harness 内置 |
| LangGraph 递归限 | recursion_limit = max_steps × 3 + 5 | 自动计算 |
| Token/时间预算 | harness_max_total_tokens / harness_max_total_ms | 可配置 |
最常见的循环场景:LLM 反复调同一个工具但得到相同结果(比如工具返回错误,LLM 不理解就重试)。重复检测会捕获这种模式。
四、并行工具执行#
Q13: 多个工具怎么并行执行的?#
LLM 一轮可能返回多个 tool_calls,系统按安全性分批执行:
# tool_runner.py — partition_tool_calls()
# 只读工具 (concurrency_safe=True) → 合并到一个 batch → asyncio.gather 并行
# 写入工具 (concurrency_safe=False) → 单独一个 batch → 串行执行
batches = partition_tool_calls(tool_calls, max_parallel=6)
# 例如: [(True, [get_metrics, search_kb, get_logs]), ← 3 个只读并行
# (False, [run_command]), ← 1 个写操作串行
# (True, [get_process_info])] ← 下一个只读python每个工具调用都包在 _safe_invoke_tool 里,异常不会 crash 整个 batch:
try:
content = await _invoke_tool(tool, args)
except Exception as exc:
content = f"[执行失败: {type(exc).__name__}: {exc}]"pythonQ14: 工具结果太长怎么办?#
每个工具有 max_result_chars 限制(通过 ToolMeta 配置)。超长结果会被截断:
if len(content) > meta.max_result_chars:
content = content[:meta.max_result_chars] + f"\n[truncated: {len(content)} → {meta.max_result_chars}]"python防止一个工具返回 10KB 的日志把 LLM 上下文撑爆。Replanner 里也会截断 past_steps 的结果(agent_replanner_past_step_chars=2000),避免历史步骤太长。
Q15: 工具权限怎么控制?#
三态权限模型 PermissionDecision:
| 行为 | 说明 | 使用场景 |
|---|---|---|
allow | 直接执行 | 只读工具 (查指标、搜知识库) |
ask | 暂停等人工审批 | 高危操作 (重启服务、修改配置) |
deny | 拒绝并告诉 LLM 原因 | 黑名单工具 |
ask 模式会触发并发槽的 Pause/Resume——等审批时让出执行槽,审批通过后重新抢槽继续。
五、预算控制#
Q16: 一次诊断要花多少 token?怎么控制成本?#
三层预算控制:
# config.py — 可配置限额
agent_max_steps = 5 # 步数上限
harness_max_total_tokens = 0 # token 上限 (0=不限)
harness_max_total_ms = 0 # 时间上限 (0=不限)
executor_max_iters = 4 # 单步内 LLM↔工具最大轮数python实际消耗取决于模式和问题复杂度:
| 模式 | LLM 调用次数 | 大致 token | 大致费用 |
|---|---|---|---|
| Fast (简单问题) | 5-8 次 | ~15K | ¥0.03 |
| Fast (复杂问题) | 10-15 次 | ~40K | ¥0.08 |
| Deep | 15-25 次 | ~80K | ¥0.15 |
每次诊断结束后会输出 usage 事件(input_tokens / output_tokens / total),存入 AgentRun 表供审计。
Q17: 模型分层是怎么做的?#
不同环节用不同模型,控成本又保质量:
| 环节 | 模型 | 理由 |
|---|---|---|
| SkillRouter | deepseek-v4-flash | 分类任务,便宜模型够用 |
| Planner | deepseek-v4-flash | 计划分解,不需要太强推理 |
| Executor | deepseek-v4-flash | 工具调用,快速响应优先 |
| Replanner | deepseek-v4-flash | 进展评估,结构化输出 |
| Reporter(最终报告) | deepseek-v4-pro | 最终输出质量要高 |
Router/Planner/Replanner 都用结构化输出(ainvoke_structured),LLM 必须返回指定 schema 的 JSON,解析失败会重试。
六、Deep 模式#
Q18: Deep 模式的 4 个专家 Agent 是怎么并行的?证据怎么汇到 RCA Judge?#
LangGraph 的 add_edge 支持多个节点指向同一个下游节点,形成 fan-out + fan-in:
# deep_diagnosis_graph.py
for name in ["log_agent", "metric_agent", "infra_agent", "runbook_agent"]:
wf.add_edge("evidence_plan", name) # fan-out: 4 个同时启动
wf.add_edge(name, "rca_judge") # fan-in: 都完成后进入 JudgepythonLangGraph 自动处理并行执行和 join barrier——4 个专家全部完成后,它们产出的 Evidence 汇总到 state.evidences,RCA Judge 一次性读取并裁决。
Q19: RCA Judge 怎么从一堆证据里定根因?(高频追问)#
4 个 Agent 的证据直接交给 RCA Judge(LLM),中间没有额外的 LLM 打分/归并环节:
log_agent → Evidence(source=LOG, "OOM killed at 14:32") ─┐
metric_agent → Evidence(source=METRIC, "memory 99% since 14:30") ─┤
infra_agent → Evidence(source=MCP, "swap disabled") ─┼→ RCA Judge (LLM, 只调一次)
runbook_agent → Evidence(source=RUNBOOK, "SOP: maxmemory-policy") ─┘ │
▼
根因排序 + 判定理由
+ 3-5 条支持证据引用plaintextRCA Judge 的约束:
- 只看结构化 summary,不读原始 content 原文(控制 prompt 长度)
- 只调一次 LLM,输出结构化 JSON:排序后的候选列表 + ≤200 字判定理由 + 关键证据引用
- 解析失败降级:LLM 返回非法 JSON 时,退化取第一条候选作为根因
为什么不在 Judge 之前加一轮 LLM 归并/打分:
- 成本:4 个 Agent 已各调了 LLM,再加归并就是 6 次。Judge 拿到全部证据一次性判断就够
- 可复现:如果中间加 LLM 归并,同一批证据跑两次可能产出不同候选列表,下游 Judge 的判定也跟着漂移
- 简单:少一个环节就少一个出错点,调试时直接看”Agent 出了什么 → Judge 判了什么”,链路清晰
Q20: Fast 和 Deep 跑出来结论不一样怎么办?#
这是一个开放性问题,目前没有自动仲裁机制。实际做法:
- 优先信任 Deep:它看了更多证据(4 个子 Agent 并行取证 vs Fast 的单链)。
- 人工判断:诊断报告里有
past_steps(每一步做了什么、看到了什么),运维可以回溯推理过程。 - 经验沉淀:如果 Deep 的结论被运维确认正确,会写入 LLM Wiki,下次类似告警 Router 会优先选对应的 Skill。
Q20.5: Deep 模式最后要”动手处置”,怎么让 LLM 给出的执行命令是安全的?#
这是”档位 2 执行闭环”(RemediationPlanner → Executor → Verify),核心思路一句话:LLM 只当 planner 填结构化字段,命令由代码拼;执行是硬编码的、且逐层收敛。
为什么不让 LLM 直接吐命令字符串:早期版本借鉴 HolmesGPT run_kubectl_command,让 LLM 生成 docker restart demo-nginx 这种整条命令,再用护栏 shlex.split 解析后拦元字符、flag 黑名单、docker…docker 链式。问题是——你在解析一段不可信字符串,;、$(...)、-v(删卷)、--privileged、多目标这些都得逐一防,漏一个就是注入。这是”防御不可信输入”的思路,天生被动。
typed action 的做法:让 LLM 只输出 typed JSON,命令由 command_guard.build_* 按固定骨架拼:
# 1) LLM 输出(planner 节点,deep_diagnosis_graph.remediation_planner_node)
{"action_type": "docker", "verb": "restart", "target": "demo-nginx", "why": "容器 OOM 已退出"}
# 2) command_guard.build_docker_action() —— 校验字段 + 拼 argv(节选真实实现)
def build_docker_action(verb, target, timeout_sec=None) -> GuardResult:
v = verb.strip().lower()
if v not in WRITE_VERB_ALLOWLIST: # {restart,stop,start,kill,pause,unpause}
return _deny(...) # 白名单外 verb 直接拒
if not _TARGET_RE.match(target.strip()): # ^[A-Za-z0-9][\w.\-]{0,63}$
return _deny(...) # 元字符/路径/空格进不了正则
argv = ["docker", v] # ← 程序名 + 结构:代码写死
if timeout_sec is not None: # ← 唯一的 flag,值来自类型化 int
argv += ["-t", str(int(timeout_sec))]
argv.append(target.strip())
return GuardResult(allowed=True, argv=argv) # ["docker","restart","demo-nginx"]pythonverb/signal 是枚举、target 过正则、pid 强制 int、timeout_sec 是 int 字段(取代旧的 -t 5 flag)—— -9、--privileged、; rm -rf、$(id)、多目标在类型层面根本表达不出来,整类注入风险从源头消失,而不是靠”拦得全不全”。
Q20.6: 那命令到底哪来的?是预先定义一堆命令模板让大模型填字段吗?#
不是存了一堆完整命令模板供 LLM 挑,而是”命令骨架写死在代码里,LLM 只填骨架上的槽位”。 看上面 build_docker_action,拼出来的骨架恒定是 docker <verb> [-t N] <target>,哪些死、哪些 LLM 填:
| 部分 | 谁决定 | 约束 |
|---|---|---|
可执行程序(docker/systemctl/kill) | 代码写死 | LLM 碰不到 |
| argv 的结构和顺序 | 代码写死 | LLM 碰不到 |
verb(restart/stop/…) | LLM 填 | 必须 ∈ 枚举白名单,否则 deny |
target/unit | LLM 填 | 必须过正则,否则 deny |
pid/signal/timeout_sec | LLM 填 | int / 枚举 / 范围校验 |
所以 LLM 有语义自由(根据根因决定”重启哪个容器”),但对”命令怎么成形”零自由——引不进新程序、新 flag、新命令形态。
和被删掉的”档位 0”对比(这是加分点,能讲清演进):
| 档位 0(已删) | freeform(旧) | typed action(现在) | |
|---|---|---|---|
| 命令来源 | runbook YAML 数据模板 docker restart {{target}},逐场景写 | LLM 生成整条命令字符串 | 代码里参数化的 argv 骨架,一套覆盖一个域所有 verb |
| 安全性 | 最确定但覆盖窄、加动作贵 | 灵活但要防注入 | 枚举+正则即上界,注入不可表达 |
一句话:typed action 把”一堆固定命令模板”压缩成”一个由枚举 verb 参数化的代码骨架”,落在”死模板”和”自由生成”中间——比档位 0 灵活(不用逐场景写),比 freeform 安全(LLM 出不了命令字符串)。
Q20.7: 从 LLM 出字段到真正执行,完整链路是什么?每道门在哪?#
remediation_planner_node 出 plan → remediation_executor_node 调 execute_command_with_approval(),逐门收敛:
① 护栏 fail-fast:build_action(action_type, params) 不过 → guard_rejected(不浪费审批)
② restricted 硬门:evaluate_permission(remediation_context=True)
③ 人工审批:ASK_DESTRUCTIVE → 落 approval_requests,等人 allow/deny
④ 一次性消费:consume_if_approved()(原子,防重放)
⑤ 调 MCP 工具:tool.ainvoke({"verb":..,"target":..}) ← typed 字段直接透传
⑥ MCP 侧再校验:run_docker_command 内部又跑一遍 build_docker_action → subprocess(argv)plaintext每道门对应的真实机制:
| 门 | 机制 | 代码 |
|---|---|---|
| restricted 硬墙 | 写工具 ToolMeta.restricted=True;诊断阶段任何模式(含 BYPASS)都 deny,放在 BYPASS 判断之前,只有 remediation_context=True 放行 | permissions.py Layer 0.5 |
| command_guard ×2 | build_action 校验字段 + 拼 argv,app 侧 fail-fast + MCP 侧再校验一次(同一纯函数跑两处,即便 app 被绕过 MCP 也拦) | command_guard.py |
| 人工审批 | ASK_DESTRUCTIVE 命中 ask → create_request 落库,wait_for_decision 轮询,等审批时 current_slot.pause() 让出并发槽 | approvals.py |
| 一次性消费 | UPDATE ... WHERE status='approved' 原子迁移到 consumed,只有抢到的返回 True | consume_if_approved |
再叠加默认全关:REMEDIATION_EXECUTE_ENABLED=false(只规划不执行)+ MCP 侧 env 硬开关 DOCKER_ALLOW_EXEC/HOST_ALLOW_*(默认 off,改了要重启对应 MCP server)+ kill 的动态保护名单(PID→进程名,命中 postgres/redis/docker/agent 自身 → 拒杀)。
最终防线是人工审批:即便前面全放行,写操作也必须人在审批中心点”批准”才真跑,审批卡上带命令、根因、置信度、关联证据,不翻报告就能决策。
诚实的边界(面试主动说,是加分项):MCP 执行器目前直接在宿主跑 subprocess,爆炸半径 = 整台机器,还没上 OS 级沙箱;host 域和”改配置类”处置暂无自动 verify/回滚。更进一步的 OS 沙箱 / broker + JWT 签名审批票据(对标 HolmesGPT 的 Kubernetes Remediation MCP)我评估过,但在单机规模属于过度设计、收益不匹配成本,是明确的中期演进项——这也是我把”人工审批 + 爆炸半径钉死”定为当前合格线的判断。
Q21: 执行完修复后怎么自动复核的?#
修复链路的最后一步是 verify_node,在 remediation_executor 之后、report 之前自动执行:
RemediationPlanner → RemediationExecutor → verify_node → Report → ENDplaintextverify_node 的逻辑:
- 前置检查:修复动作没有实际执行(被审批拒绝 / guard 拦截)→ 直接跳过
- 域区分:目前只有 Docker 域支持自动复核(调只读的
docker_ps工具);host 域和进程 kill 域跳过,标注”需人工确认” - 执行检查:调
docker_ps获取容器状态,逐目标检查容器名是否出现且状态为Up - 结果记录:每个目标写一条
remediation_verify类型的 evidence,可追溯 - 失败通知:未恢复的目标发
remediation_verify_failed流式事件,通知人工介入(不自动回滚)
# deep_diagnosis_graph.py — _verify_recovered()
def _verify_recovered(target: str, ps_text: str) -> bool:
for line in ps_text.splitlines():
if target in line and "up" in line.lower():
return True
return Falsepython最终报告输出:"复核: 2/3 目标已确认恢复"。
面试追问:为什么不自动回滚?
自动回滚的风险比不回滚更大。“容器没恢复”可能是需要更长时间启动,也可能是配置问题需要人工介入。自动回滚等于又执行一次不确定的操作。当前策略是:通知人,把复核结果 + 原始命令 + 审批 ID 一起推给运维,让人决定下一步。
面试追问:为什么只做 docker ps 不做业务层健康检查?
修复动作是”重启容器”级别,业务恢复需要更长时间(连接池重建、缓存预热)。卡在诊断流程里等业务健康不现实。docker ps + Up 验证的是”进程活了”,业务层的验证应该交给独立的健康检查系统(如 Kubernetes readiness probe)。
Q21.5: 审批环节为什么不是简单的 human-in-the-loop,而是搞了一套 checkpointer + interrupt()?#
现在生产默认路径(remediation_sync_interrupt_mode=False)其实一直就是 human-in-the-loop——Q20.7 里的③,execute_command_with_approval() 协程内 await wait_for_decision() 轮询等人工 allow/deny。问题是:这个”等”是真的占着资源在等——诊断这条协程占用 Worker 的一个执行槽,diagnosis_worker.py 严格串行消费 Redis Streams,一条消息没处理完不会去拿下一条,等审批的几分钟里新告警会排队。
为什么不干脆改成纯异步(处置直接丢给独立进程,诊断马上返回):项目里已经有这样一条路径(remediation_decoupled_mode),但它引入了新问题——诊断报告写完时,处置到底有没有生效是不确定的,报告和执行结果脱节成两条通知,体验割裂;而且如果没人处理,执行结果永远不会反馈回诊断链路(唯一的隐式闭环是等告警系统自己重新触发,脆弱且不是设计意图)。
方案:LangGraph 原生的 checkpointer + interrupt() + Command(resume=...)——图跑到审批点时把完整 state 序列化进 Postgres checkpoint,interrupt() 让这次 astream() 调用正常结束(不是异常),Worker 的协程立刻释放,可以去处理下一条告警;人工在审批中心点”批准”后,用同一个 thread_id 调 Command(resume=...) 恢复图从中断点接着跑,紧接着做执行 + 复核,报告里能带上真实结果。
两个关键行为是写最小 demo 图实测出来的,不是看文档猜的:
# 最小验证: guard(一次性) -> wait_approval(interrupt) -> apply_decision(一次性)
# 第一次 astream 到 wait_approval 命中 interrupt() 后自然结束(不是异常):
# {'guard': {...}}
# {'__interrupt__': (Interrupt(value={...}, id=...),)}
# 用 Command(resume="approved") 恢复后重新观察调用次数:
# guard 只跑了 1 次;wait_approval 跑了 2 次(重放了一遍再拿到 resume 值)python- 恢复时只有包含
interrupt()调用的那个节点会整体重跑,已经提交 checkpoint 的上游节点不会重放。这决定了必须把原来的单一remediation_executor_node拆成三个节点:remediation_guard(护栏判定 + 落审批请求,只跑一次,side effect 安全)→remediation_wait_approval(节点体只有interrupt()一行,不能有任何副作用代码)→remediation_apply_decision(消费决策 + 执行,只在真正拿到决策后跑一次)。 - 并发 resume 同一个 pending thread 是真实竞态,不是理论风险:两个协程同时对同一行调
Command(resume=...),都会成功跑完、后写覆盖前写——LangGraph 不会在库层面挡。所以必须在approval_requests表上加一个resume_claimed_at原子占位字段(和现有”一次性消费”claim_for_execution同款手法),谁先UPDATE ... WHERE resume_claimed_at IS NULL抢到,谁才能真正触发恢复;否则 decide() 端点和”审批超时自动降级”两条路径一旦撞车,处置命令可能被执行两次。
现状(诚实边界,主动说是加分项):已经完整实现”命中审批 → 挂起释放资源 → 人工批准 → 恢复执行 → 报告带结果”这条闭环,decide() 端点用 asyncio.wait(不是 asyncio.wait_for——后者超时会把还在跑的恢复任务取消掉,处置命令可能执行到一半被打断)限时同步等恢复,超时转后台继续跑。但默认关闭(remediation_sync_interrupt_mode=False),因为还缺一块:审批超时后的自动降级——现在如果没人处理某条审批,图会一直挂着,不像老的轮询路径那样在 approvals_timeout_sec 后自动判超时转失败/异步。这是打开这个开关前必须先补的一块,是”设计已经想清楚、按阶段有意留一部分未实现”,不是遗漏——因为整条新链路默认休眠,不打开就不影响任何现有生产行为,属于可以随时安全暂停的节点。
Q21.6: 你把处置从”命令建模”重构成”资源建模 + 事务化工具”,为什么?改了什么?#
这是我对标成熟变更管理产品(AWS SSM Automation、Terraform Provider、Kubernetes 资源模型)做的一次顶层重构。核心判断:Q20.5~Q20.7 里那套”LLM 填 verb/target、command_guard 拼 argv”虽然安全,但它的心智模型还是”命令”——护栏是唯一防线,没有快照、没有回滚、验证与执行分离且只覆盖容器域。 成熟产品不这么想问题,它们把处置对象建模成”资源”,把操作建模成”资源状态机上的一条迁移边”。我按这个思路重做了四件事:
① 资源建模(不是命令建模)。系统里不再存在命令字符串。LLM 只声明意图 {"resource":"container://nginx","operation":"stop","params":{}}——连 verb 都碰不到。资源用 URI 标识(container:// / service://systemctl/x / process://pid / file:///path / database://mysql/x / deployment://compose/x),每类资源有状态机(容器 running/paused/stopped/absent),每个写操作是状态机上一条带前置条件的迁移边,全部注册在 app/remediation/model.py 的 Operation Registry(单一真源,app 侧和 6 个 MCP server 共享同一份定义)。红队测试:22 条对抗意图(注入 URI / 白名单外操作 / 保护资源 / 越界参数)block_rate 100%、合法意图误拦 0%。
② 可逆性形式化 + 自动 Recovery Plan。每个操作声明可逆性三分类:逆迁移型(stop↔start,Recovery Plan = 状态机逆操作)、快照恢复型(file_write / db_restore / rollout,执行前自动落物理备份带 sha256,Recovery Plan = restore 到备份)、不可逆(restart / process_kill,审批卡显式标 ⚠️ 不可回滚)。Recovery Plan 在提案阶段自动生成,随审批整体授权——审批人批准的不只是动作,是”动作 + 回滚预案”这个整体,这正是变更单必须带回滚方案的做法。
③ Tool = 完整事务。每个写工具内部是一个原子事务:快照 → 前置校验(CAS) → 应用 → 验证(轮询+稳定窗口) → verify 失败自动补偿。三个关键点:(a) CAS 漂移检测——审批时快照的状态作为 expected_state 传入,执行时若资源状态已变(“批的是 A 态、执行时已是 B 态”)直接 state_drift 中止、零变更;(b) verify 自带——不再像老 verify_node 那样只 grep docker_ps,容器/服务/进程/文件/DB 全域都在事务内轮询后置条件;(c) verify 失败自动回滚——可逆操作验证不通过就执行 Recovery Plan 恢复原状(对标 Saga 补偿事务)。我用真实 Docker 端到端验证过五条路径:committed / 崩溃容器 start 触发 rolled_back / CAS state_drift 零变更 / File 备份-写入-恢复闭环 / process_kill 审批卡标不可回滚。
④ 工具即动作目录。旧的泛化执行器 run_docker_command(verb, target) 删掉了——verb 当参数意味着 LLM 仍持有”动词自由度”。换成每个动作一个参数封闭的独立工具(container_stop / container_start / …),能力最小化由工具边界本身保证,而不只靠护栏兜底。
踩过的坑(真实发现,加分点):File 事务的备份文件名原本用”秒级时间戳 + pid”,端到端测试暴露出——同一秒内同进程的 file_write(备份原文件)和紧接着的 file_restore(备份当前态)会撞名,后者覆盖前者,导致 restore 读到错误内容、verify 失败被自动回滚。加了随机后缀才修好。这个 bug 单元测试测不出来(mock 掉了文件系统),必须真实跑闭环才会现形。
诚实边界:宿主 subprocess 执行、OS 级沙箱、审批超时自动降级仍是既有的中期演进项(同 Q20.7 / Q21.5);Deployment 域基于 docker compose 而非 k8s,但资源模型让扩展只需新增一个 Resource Provider。
七、设计取舍#
Q22: 为什么不让 LLM 自己决定用什么工具,而要先选 Skill 再给工具?#
Token 效率:20+ 个工具的 schema 注入到 system prompt 里要占 ~3000 token,每一轮 LLM 调用都带着。Skill 过滤后只剩 5-8 个工具,省掉一半 token。
准确率:实测不限工具时,LLM 会尝试不相关的工具(如排查 Redis 时调 Docker 工具),浪费步骤。Skill 白名单消除了这类噪音。
可审计:Router 选了哪个 Skill、confidence 多少、reason 是什么,全部可追溯。不像”LLM 自己选工具”那样黑箱。
Q23: 如果重来 Agent 这块,你会改什么?#
- Planner 输出更结构化:目前 plan 是
List[str],没有说每步该用什么工具。可以改成List[Step(description, expected_tools)],让 Executor 不用猜。 - Evidence 持久化:目前 past_steps 只在内存里,诊断中途进程挂了就丢了。应该每步写回数据库。
- 工具调用缓存:同一次诊断中,
get_system_metrics可能被调多次(Planner 觉得要查,Executor 也觉得要查)。可以在 session 级别缓存工具返回值。 - 消融实验自动化:目前没有系统化地对比不同配置的诊断质量。应该建一个 benchmark pipeline,自动对比 max_steps=3 vs 5、有无 Rerank、不同模型等组合的效果。