面试知识库

面试 Q&A — 多智能体编排#

覆盖:LangGraph 状态机、Skill-first 路由、Planner-Executor-Replanner 循环、 Fast/Deep 双模式、并行工具执行、预算控制、Skill 重路由。


一、整体架构#

Q1: 举个具体例子,一条告警从进来到出诊断报告,Agent 是怎么编排的?#

场景:Alertmanager 推了一条 HighMemoryUsage 告警,redis-prod-01 内存 99%。warning 级走 fast 模式诊断,看 Agent 怎么一步步推理到根因。

四个节点依次流转:

  1. SkillRouter:LLM 看到告警里有 redismemory,从 Skill 菜单中选中 redis_ops(置信度 0.92)。同时查了 LLM Wiki——上周同类告警用 redis_ops 成功过,进一步确认选择。选中后,Executor 只能看到 redis_ops 允许的 5 个工具(Redis + System),而不是全部 20+ 个。LLM 调用失败时,关键词兜底规则 query 含 “Redis” → redis_ops

  2. Planner:注入 redis_ops 的 Playbook(标准操作流程),LLM 分解出 4 步计划:查内存指标 → 检查慢查询和大 key → 查 maxmemory-policy 配置 → 搜知识库找 SOP。

  3. 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 秒)。

  4. Reporter(deepseek-v4-pro):汇总所有证据,生成结构化报告——根因 “maxmemory-policy=noeviction 导致内存打满无法淘汰”,建议 “改为 allkeys-lru + 排查 KEYS * 调用来源”。

节点模型耗时产出
SkillRouterdeepseek-v4-flash~2s选中 redis_ops
Plannerdeepseek-v4-flash~3s4 步计划
Executor × 4deepseek-v4-flash~30s4 条证据链
Reporterdeepseek-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
  1. SkillRouter:LLM 从 Skill 菜单中选一个最匹配的 Skill(如 redis_opsdisk_pressure
  2. Planner:基于选中 Skill 的 Playbook,分解出 3-6 步执行计划
  3. Executor:执行 plan[0]——调 LLM + MCP 工具取证
  4. Replanner:评估进展,决定 继续 / 结束出报告 / 换 Skill 重来

Executor ⇄ Replanner 形成循环,最多迭代 5 次(agent_max_steps),防止 LLM 走入死循环。

Q3: 为什么用 LangGraph 而不是自己写循环?#

LangGraph 提供了三个自己写难以做好的东西:

  1. 状态管理PlanExecuteState 用 TypedDict + Annotated[List, operator.add] 做累加字段,past_steps 自动追加不用手动管理。
  2. 条件边:Replanner 出来有三个走向(executor / planner / END),用 add_conditional_edges 声明式定义,比 if-else 链更清晰。
  3. 流式输出graph.astream() 自动把每个节点的输出 yield 出来,前端 SSE 直接对接。

如果重来会不会不用 LangGraph?说实话,对 fast 模式可能不用——就是个循环。但 deep 模式有 4 个子 Agent 并行 fan-out + join barrier,LangGraph 的 StateGraph 处理并行节点很方便。

Q4: Fast 和 Deep 模式有什么区别?#

维度FastDeep
图结构单链循环 (Router→Plan→Exec→Replan)8 节点 DAG (fan-out + fan-in)
子 Agent4 个专家 (Log/Metric/Infra/Runbook)
LLM 调用~5-8 次~15-20 次
耗时30s-2min2-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_iterstransitions 里有 EXECUTOR_MAX_STEPS/REPLANNER_MAX_STEPS_FORCE单链没查完,被强制收尾
router_missedtransitions 里有 ROUTER_FALLBACK_GENERIC/ROUTER_LLM_FAILED没匹配到专门剧本,单链缺方向
reroutedreroute_count > 0fast 中途改选故障域,选域反复不稳
low_confidenceskill_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              # 选择理由
python

Router 还会查 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 存在于 SkillRegistry
python

重路由触发后,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 单独的量化数据,需要做的实验是:

  1. 有效步骤占比tool_calls 表里”结果被下游步骤或最终报告引用”的比例,按 Skill 维度统计,对比「白名单+Playbook」vs「只给白名单,Playbook 置空」两组
  2. 收敛步数:从 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)
python

Playbook 是关键——它不是让 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      Planner
plaintext

在调 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 直接继续
python

Fast-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}]"
python

Q14: 工具结果太长怎么办?#

每个工具有 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
Deep15-25 次~80K¥0.15

每次诊断结束后会输出 usage 事件(input_tokens / output_tokens / total),存入 AgentRun 表供审计。

Q17: 模型分层是怎么做的?#

不同环节用不同模型,控成本又保质量:

环节模型理由
SkillRouterdeepseek-v4-flash分类任务,便宜模型够用
Plannerdeepseek-v4-flash计划分解,不需要太强推理
Executordeepseek-v4-flash工具调用,快速响应优先
Replannerdeepseek-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: 都完成后进入 Judge
python

LangGraph 自动处理并行执行和 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 条支持证据引用
plaintext

RCA Judge 的约束:

  • 只看结构化 summary,不读原始 content 原文(控制 prompt 长度)
  • 只调一次 LLM,输出结构化 JSON:排序后的候选列表 + ≤200 字判定理由 + 关键证据引用
  • 解析失败降级:LLM 返回非法 JSON 时,退化取第一条候选作为根因

为什么不在 Judge 之前加一轮 LLM 归并/打分

  1. 成本:4 个 Agent 已各调了 LLM,再加归并就是 6 次。Judge 拿到全部证据一次性判断就够
  2. 可复现:如果中间加 LLM 归并,同一批证据跑两次可能产出不同候选列表,下游 Judge 的判定也跟着漂移
  3. 简单:少一个环节就少一个出错点,调试时直接看”Agent 出了什么 → Judge 判了什么”,链路清晰

Q20: Fast 和 Deep 跑出来结论不一样怎么办?#

这是一个开放性问题,目前没有自动仲裁机制。实际做法:

  1. 优先信任 Deep:它看了更多证据(4 个子 Agent 并行取证 vs Fast 的单链)。
  2. 人工判断:诊断报告里有 past_steps(每一步做了什么、看到了什么),运维可以回溯推理过程。
  3. 经验沉淀:如果 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"]
python

verb/signal枚举target正则pid 强制 inttimeout_secint 字段(取代旧的 -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/unitLLM 填必须过正则,否则 deny
pid/signal/timeout_secLLM 填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_nodeexecute_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 ×2build_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,只有抢到的返回 Trueconsume_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 → END
plaintext

verify_node 的逻辑:

  1. 前置检查:修复动作没有实际执行(被审批拒绝 / guard 拦截)→ 直接跳过
  2. 域区分:目前只有 Docker 域支持自动复核(调只读的 docker_ps 工具);host 域和进程 kill 域跳过,标注”需人工确认”
  3. 执行检查:调 docker_ps 获取容器状态,逐目标检查容器名是否出现且状态为 Up
  4. 结果记录:每个目标写一条 remediation_verify 类型的 evidence,可追溯
  5. 失败通知:未恢复的目标发 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 False
python

最终报告输出:"复核: 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_idCommand(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
  1. 恢复时只有包含 interrupt() 调用的那个节点会整体重跑,已经提交 checkpoint 的上游节点不会重放。这决定了必须把原来的单一 remediation_executor_node 拆成三个节点:remediation_guard(护栏判定 + 落审批请求,只跑一次,side effect 安全)→ remediation_wait_approval(节点体只有 interrupt() 一行,不能有任何副作用代码)→ remediation_apply_decision(消费决策 + 执行,只在真正拿到决策后跑一次)。
  2. 并发 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.pyOperation 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 这块,你会改什么?#

  1. Planner 输出更结构化:目前 plan 是 List[str],没有说每步该用什么工具。可以改成 List[Step(description, expected_tools)],让 Executor 不用猜。
  2. Evidence 持久化:目前 past_steps 只在内存里,诊断中途进程挂了就丢了。应该每步写回数据库。
  3. 工具调用缓存:同一次诊断中,get_system_metrics 可能被调多次(Planner 觉得要查,Executor 也觉得要查)。可以在 session 级别缓存工具返回值。
  4. 消融实验自动化:目前没有系统化地对比不同配置的诊断质量。应该建一个 benchmark pipeline,自动对比 max_steps=3 vs 5、有无 Rerank、不同模型等组合的效果。