摘要
:安全运维里最让人疲惫的不是处理告警本身,而是每次告警都要把同一套调查动作重复一遍 ------ 解析目标资产、翻 ES 日志、看主机指标、查防护状态、判断风险等级、等领导确认、再写报告。本文记录我的个人作品集项目
sec-investigator:如何把实习阶段的安全调查经验,重构成一个基于 LangGraph 的
受约束状态工作流
。文章会讲清楚四件事:真实场景到底在调查什么、为什么选 LangGraph 并做三层分层、编排上如何用「代码控制路由 + 模型只做取证」约束自治边界、以及确定性证据、人工审批、兜底报告这些关键机制的核心代码。所有结论都对应仓库里的真实实现,也会如实交代当前边界,不把设计目标写成已交付能力。
仓库地址 :https://github.com/Yuan1433223/sec-investigator
开篇:一条告警触发后的 30 分钟
先还原一个我实习时很熟悉的画面:凌晨,Grafana 弹出一条告警:某个域名「1 分钟聚合 5xx 趋势上涨 600%」。值班工程师要做的事情几乎是固定的:
-
先确认这个目标是什么 ------ 是域名还是 IP?它背后对应哪些节点、哪些产品 IP?走的是 WAF 还是高防(GF)链路?
-
去 Elasticsearch 拉访问日志:QPS 趋势、状态码分布、Top 客户端 IP 和 Top URL,判断是攻击还是故障;
-
去 Prometheus 看主机侧:CPU、内存、TCP 连接数、带宽,确认源站机器有没有被打满;
-
去防护系统查 CC / DDoS 状态:实时 PPS、清洗事件、攻击标记、封禁计数;
-
综合三路证据给出风险等级,如果命中严重(critical)等级,需要人工确认后才能继续;
-
最后产出一份结构化调查报告,写清根因和处置建议,再推送到飞书。
这套流程里,没有一步是真正「创造性」的,但每一步都在消耗经验带宽。新手容易漏掉某一路证据、或者把「没有数据」误判成「一切正常」;而资深工程师的价值,其实体现在「先查什么、什么数值算异常、什么情况下必须停下来等人」这些判断规则上。
我做 sec-investigator 的核心出发点就是:把这些可沉淀的判断规则,从工程师的脑子里搬到显式的状态契约和代码策略里,让模型在一个被严格约束的边界内做它擅长的事 ------ 选择查询、组织观测、撰写解释,而把路由、定级、审批这些高风险决策留给代码和人。
一次完整调查的数据流如下:
#mermaid-svg-FP1leaLGRZf86yTS{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-FP1leaLGRZf86yTS .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-FP1leaLGRZf86yTS .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-FP1leaLGRZf86yTS .error-icon{fill:#552222;}#mermaid-svg-FP1leaLGRZf86yTS .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-FP1leaLGRZf86yTS .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-FP1leaLGRZf86yTS .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-FP1leaLGRZf86yTS .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-FP1leaLGRZf86yTS .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-FP1leaLGRZf86yTS .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-FP1leaLGRZf86yTS .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-FP1leaLGRZf86yTS .marker{fill:#333333;stroke:#333333;}#mermaid-svg-FP1leaLGRZf86yTS .marker.cross{stroke:#333333;}#mermaid-svg-FP1leaLGRZf86yTS svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-FP1leaLGRZf86yTS p{margin:0;}#mermaid-svg-FP1leaLGRZf86yTS .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-FP1leaLGRZf86yTS .cluster-label text{fill:#333;}#mermaid-svg-FP1leaLGRZf86yTS .cluster-label span{color:#333;}#mermaid-svg-FP1leaLGRZf86yTS .cluster-label span p{background-color:transparent;}#mermaid-svg-FP1leaLGRZf86yTS .label text,#mermaid-svg-FP1leaLGRZf86yTS span{fill:#333;color:#333;}#mermaid-svg-FP1leaLGRZf86yTS .node rect,#mermaid-svg-FP1leaLGRZf86yTS .node circle,#mermaid-svg-FP1leaLGRZf86yTS .node ellipse,#mermaid-svg-FP1leaLGRZf86yTS .node polygon,#mermaid-svg-FP1leaLGRZf86yTS .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-FP1leaLGRZf86yTS .rough-node .label text,#mermaid-svg-FP1leaLGRZf86yTS .node .label text,#mermaid-svg-FP1leaLGRZf86yTS .image-shape .label,#mermaid-svg-FP1leaLGRZf86yTS .icon-shape .label{text-anchor:middle;}#mermaid-svg-FP1leaLGRZf86yTS .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-FP1leaLGRZf86yTS .rough-node .label,#mermaid-svg-FP1leaLGRZf86yTS .node .label,#mermaid-svg-FP1leaLGRZf86yTS .image-shape .label,#mermaid-svg-FP1leaLGRZf86yTS .icon-shape .label{text-align:center;}#mermaid-svg-FP1leaLGRZf86yTS .node.clickable{cursor:pointer;}#mermaid-svg-FP1leaLGRZf86yTS .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-FP1leaLGRZf86yTS .arrowheadPath{fill:#333333;}#mermaid-svg-FP1leaLGRZf86yTS .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-FP1leaLGRZf86yTS .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-FP1leaLGRZf86yTS .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FP1leaLGRZf86yTS .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-FP1leaLGRZf86yTS .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FP1leaLGRZf86yTS .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-FP1leaLGRZf86yTS .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-FP1leaLGRZf86yTS .cluster text{fill:#333;}#mermaid-svg-FP1leaLGRZf86yTS .cluster span{color:#333;}#mermaid-svg-FP1leaLGRZf86yTS div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-FP1leaLGRZf86yTS .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-FP1leaLGRZf86yTS rect.text{fill:none;stroke-width:0;}#mermaid-svg-FP1leaLGRZf86yTS .icon-shape,#mermaid-svg-FP1leaLGRZf86yTS .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FP1leaLGRZf86yTS .icon-shape p,#mermaid-svg-FP1leaLGRZf86yTS .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-FP1leaLGRZf86yTS .icon-shape .label rect,#mermaid-svg-FP1leaLGRZf86yTS .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FP1leaLGRZf86yTS .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-FP1leaLGRZf86yTS .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-FP1leaLGRZf86yTS :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
同意 resume
拒绝
Grafana 告警 / 人工输入
目标 + 调查问题
资产解析
目标 → 带能力标签的资产
三路取证
日志 / 主机 / 防护
确定性证据
数值提取 + 风险定级
是否存在
critical 分项?
人工审批门
interrupt 暂停
报告生成
终止 failed
结构化报告 artifact
飞书 / Web 界面 / API 交付
第 1 章 场景:安全调查到底在调查什么
1.1 一个调查问题的三个维度
动手写代码之前,我先把「安全调查」这件事按证据来源拆成了三个维度。每个维度对应一类数据源、一个专家角色和一份结构化发现(findings):
| 维度 | 专家角色 | 数据源 | 要回答的核心问题 | 产出 |
|---|---|---|---|---|
| 日志分析 | Log Detective(日志侦探) | Elasticsearch 访问日志 | 流量和状态码有没有异常?异常来自哪些 IP / URL? | logs |
| 主机检查 | Machine Check(设施体检) | Prometheus 指标 | 源站机器的 CPU、内存、TCP、带宽是否承压? | machine |
| 防护检查 | Security Guard(防护卫兵) | CC / DDoS 防护 API | 防护系统是否标记攻击?清洗和封禁情况如何? | security |
这个拆分不是随便定的,它直接决定了后面图里有几个 worker 节点、每个 worker 绑定哪一组工具。注意一个关键设计:三个专家只负责「陈述客观事实」,不负责给结论和建议。比如日志侦探的系统提示词里明确写着:
-
「403 状态码命中 17,478 次(占 78.5%)」------ 这是合格输出;
-
「403 比例高,可能是扫描行为」------ 这是越界推断,禁止;
-
「建议去查 WAF 日志」------ 这是 Reporter 才该给的建议,禁止。
根因判断和处置建议被统一收敛到最后的 Reporter 节点,避免「每个节点都猜一个根因」导致的口径冲突。
1.2 三个内置的真实事故场景
仓库里保留了三个来源于真实生产事故的场景(docs/incidents.md),它们同时也是 fake 数据源和回放测试的基准。理解这三个场景,就理解了这套系统要处理的典型因果链(域名脱敏处理):
| # | 目标 | 告警现象 | 黄金结论(根因) | 关键证据特征 |
|---|---|---|---|---|
| 1 | game.*****.net |
4xx 突增 1000% | 分布式爬虫 / 批量扫描 | 404 占 72%(探测无效路径)、499 占 25%(爬虫提前断连),源站 5xx 极少,主机健康 |
| 2 | api2.*****.cn |
5xx 突增 600% | 源站应用层故障 | 源站直接返回 502,5xx 占比约 100%,响应仅 0.07--0.10s(快速报错而非超时),单机源站成为单点 |
| 3 | ********i32onew.liu6t.cn |
5xx 超 6000 次 | 高并发下源站过载 | QPS 从 31.5 万 /min 飙到 93.5 万 /min,主源站承载超 99% 请求后短暂过载,全部 5xx 均为 502,流量回落后自动恢复 |
这三个场景刻意覆盖了三种容易被混淆的根因:异常客户端流量(场景 1)、应用层故障(场景 2)、容量过载(场景 3)。它们的表象都是「状态码异常」,但区分依据藏在不同维度的证据组合里 ------ 这正是单看一路日志会误判、必须多维度交叉验证的原因。
需要诚实说明:这些是历史事故记录,fake 数据只保留了选定的因果信号,聚合比例和时间窗可能与原始事故不同;回放测试还会预置 findings 并 Mock 模型,因此不能据此宣称系统具备通用诊断准确率。
1.3 从「人工排查」到「状态工作流」的映射
我把人工排查的每一步,映射成图里的一个节点和一次状态变更。这种一一对应关系,保证了编排不是凭空设计,而是真实工作流的工程化:
#mermaid-svg-LMvteBaT9tAYK3AF{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-LMvteBaT9tAYK3AF .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-LMvteBaT9tAYK3AF .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-LMvteBaT9tAYK3AF .error-icon{fill:#552222;}#mermaid-svg-LMvteBaT9tAYK3AF .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-LMvteBaT9tAYK3AF .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-LMvteBaT9tAYK3AF .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-LMvteBaT9tAYK3AF .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-LMvteBaT9tAYK3AF .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-LMvteBaT9tAYK3AF .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-LMvteBaT9tAYK3AF .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-LMvteBaT9tAYK3AF .marker{fill:#333333;stroke:#333333;}#mermaid-svg-LMvteBaT9tAYK3AF .marker.cross{stroke:#333333;}#mermaid-svg-LMvteBaT9tAYK3AF svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-LMvteBaT9tAYK3AF p{margin:0;}#mermaid-svg-LMvteBaT9tAYK3AF .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-LMvteBaT9tAYK3AF .cluster-label text{fill:#333;}#mermaid-svg-LMvteBaT9tAYK3AF .cluster-label span{color:#333;}#mermaid-svg-LMvteBaT9tAYK3AF .cluster-label span p{background-color:transparent;}#mermaid-svg-LMvteBaT9tAYK3AF .label text,#mermaid-svg-LMvteBaT9tAYK3AF span{fill:#333;color:#333;}#mermaid-svg-LMvteBaT9tAYK3AF .node rect,#mermaid-svg-LMvteBaT9tAYK3AF .node circle,#mermaid-svg-LMvteBaT9tAYK3AF .node ellipse,#mermaid-svg-LMvteBaT9tAYK3AF .node polygon,#mermaid-svg-LMvteBaT9tAYK3AF .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-LMvteBaT9tAYK3AF .rough-node .label text,#mermaid-svg-LMvteBaT9tAYK3AF .node .label text,#mermaid-svg-LMvteBaT9tAYK3AF .image-shape .label,#mermaid-svg-LMvteBaT9tAYK3AF .icon-shape .label{text-anchor:middle;}#mermaid-svg-LMvteBaT9tAYK3AF .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-LMvteBaT9tAYK3AF .rough-node .label,#mermaid-svg-LMvteBaT9tAYK3AF .node .label,#mermaid-svg-LMvteBaT9tAYK3AF .image-shape .label,#mermaid-svg-LMvteBaT9tAYK3AF .icon-shape .label{text-align:center;}#mermaid-svg-LMvteBaT9tAYK3AF .node.clickable{cursor:pointer;}#mermaid-svg-LMvteBaT9tAYK3AF .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-LMvteBaT9tAYK3AF .arrowheadPath{fill:#333333;}#mermaid-svg-LMvteBaT9tAYK3AF .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-LMvteBaT9tAYK3AF .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-LMvteBaT9tAYK3AF .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-LMvteBaT9tAYK3AF .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-LMvteBaT9tAYK3AF .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-LMvteBaT9tAYK3AF .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-LMvteBaT9tAYK3AF .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-LMvteBaT9tAYK3AF .cluster text{fill:#333;}#mermaid-svg-LMvteBaT9tAYK3AF .cluster span{color:#333;}#mermaid-svg-LMvteBaT9tAYK3AF div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-LMvteBaT9tAYK3AF .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-LMvteBaT9tAYK3AF rect.text{fill:none;stroke-width:0;}#mermaid-svg-LMvteBaT9tAYK3AF .icon-shape,#mermaid-svg-LMvteBaT9tAYK3AF .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-LMvteBaT9tAYK3AF .icon-shape p,#mermaid-svg-LMvteBaT9tAYK3AF .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-LMvteBaT9tAYK3AF .icon-shape .label rect,#mermaid-svg-LMvteBaT9tAYK3AF .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-LMvteBaT9tAYK3AF .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-LMvteBaT9tAYK3AF .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-LMvteBaT9tAYK3AF :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 图节点
人工动作
确认目标身份与归属
按经验顺序查日志/主机/防护
对照阈值判断是否异常
严重事件上报等确认
写报告并同步
asset_resolution
资产解析节点
supervisor + 3 个 worker
路由 + 受限取证
EvidenceCollector + EvidencePolicy
提取与定级
approval_gate
interrupt 审批门
reporter
报告节点
第 2 章 框架:为什么是 LangGraph,以及三层架构
2.1 技术选型:为什么用状态图而不是单条 Chain
在动手重构前,我评估过几种常见的 Agent 组织方式:
| 方案 | 优点 | 为什么不适合本项目 |
|---|---|---|
| 单条 LLM Chain(提示词 + 少量工具) | 简单、上手快 | 无法表达「按资产并行、结果汇总、条件性人工介入」这类循环与分支;路由全靠模型自觉,不可控 |
| 完全自主的 ReAct Agent(一把工具全给它) | 灵活、模型自由度高 | 安全场景下自治边界必须可控:模型可能漏查、重复调用、甚至跳过审批,无法保证关键步骤一定执行 |
| 显式状态图(LangGraph) | 节点、边、状态、循环、检查点、中断都是一等公民 | 需要自行设计图结构,前期成本更高 |
安全调查的本质是一个有明确阶段、有循环回退、有强制人工节点、还要能暂停恢复的过程 ,这与状态图模型天然契合。LangGraph 把这些能力做成了框架原语,所以最终选定 langgraph==1.1.9,并坚持一条硬约束:把 LangGraph 当依赖,绝不复制或修改框架代码;遇到框架问题只在应用层绕开。
2.2 整体技术栈
后端运行时与前端的主要依赖(来自 pyproject.toml 与 web/package.json):
| 层 | 技术 | 作用 |
|---|---|---|
| Agent 编排 | LangGraph 1.1.9、LangChain 0.3 | 状态图、ReAct 子代理、检查点、中断 |
| 模型接入 | langchain-openai、langchain-anthropic | 多供应商模型,OpenAI 兼容 / Anthropic 双后端 |
| 数据 / 检索 | Elasticsearch 8、Prometheus(HTTP)、pymilvus、pymongo | 日志、指标、向量库与文档库(RAG) |
| Web 服务 | FastAPI、uvicorn、pydantic v2、pydantic-settings | HTTP 接口、SSE 流、配置与数据校验 |
| 前端 | React 19、Vite、Tailwind v4、shadcn/ui、TypeScript | 调查工作台、事件流展示、审批弹窗 |
| 工程化 | uv、pytest、ruff、mypy、GitHub Actions | 依赖管理、测试、静态检查与 CI |
运行环境要求 Python ≥ 3.12,默认端口 18230。
2.3 三层架构:runtime /security/surfaces
整个 src/ 被划分为三个职责清晰的包,依赖方向是单向的:
#mermaid-svg-bLBGonJR5zVHmYWy{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-bLBGonJR5zVHmYWy .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-bLBGonJR5zVHmYWy .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-bLBGonJR5zVHmYWy .error-icon{fill:#552222;}#mermaid-svg-bLBGonJR5zVHmYWy .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-bLBGonJR5zVHmYWy .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-bLBGonJR5zVHmYWy .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-bLBGonJR5zVHmYWy .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-bLBGonJR5zVHmYWy .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-bLBGonJR5zVHmYWy .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-bLBGonJR5zVHmYWy .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-bLBGonJR5zVHmYWy .marker{fill:#333333;stroke:#333333;}#mermaid-svg-bLBGonJR5zVHmYWy .marker.cross{stroke:#333333;}#mermaid-svg-bLBGonJR5zVHmYWy svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-bLBGonJR5zVHmYWy p{margin:0;}#mermaid-svg-bLBGonJR5zVHmYWy .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-bLBGonJR5zVHmYWy .cluster-label text{fill:#333;}#mermaid-svg-bLBGonJR5zVHmYWy .cluster-label span{color:#333;}#mermaid-svg-bLBGonJR5zVHmYWy .cluster-label span p{background-color:transparent;}#mermaid-svg-bLBGonJR5zVHmYWy .label text,#mermaid-svg-bLBGonJR5zVHmYWy span{fill:#333;color:#333;}#mermaid-svg-bLBGonJR5zVHmYWy .node rect,#mermaid-svg-bLBGonJR5zVHmYWy .node circle,#mermaid-svg-bLBGonJR5zVHmYWy .node ellipse,#mermaid-svg-bLBGonJR5zVHmYWy .node polygon,#mermaid-svg-bLBGonJR5zVHmYWy .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-bLBGonJR5zVHmYWy .rough-node .label text,#mermaid-svg-bLBGonJR5zVHmYWy .node .label text,#mermaid-svg-bLBGonJR5zVHmYWy .image-shape .label,#mermaid-svg-bLBGonJR5zVHmYWy .icon-shape .label{text-anchor:middle;}#mermaid-svg-bLBGonJR5zVHmYWy .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-bLBGonJR5zVHmYWy .rough-node .label,#mermaid-svg-bLBGonJR5zVHmYWy .node .label,#mermaid-svg-bLBGonJR5zVHmYWy .image-shape .label,#mermaid-svg-bLBGonJR5zVHmYWy .icon-shape .label{text-align:center;}#mermaid-svg-bLBGonJR5zVHmYWy .node.clickable{cursor:pointer;}#mermaid-svg-bLBGonJR5zVHmYWy .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-bLBGonJR5zVHmYWy .arrowheadPath{fill:#333333;}#mermaid-svg-bLBGonJR5zVHmYWy .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-bLBGonJR5zVHmYWy .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-bLBGonJR5zVHmYWy .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-bLBGonJR5zVHmYWy .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-bLBGonJR5zVHmYWy .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-bLBGonJR5zVHmYWy .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-bLBGonJR5zVHmYWy .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-bLBGonJR5zVHmYWy .cluster text{fill:#333;}#mermaid-svg-bLBGonJR5zVHmYWy .cluster span{color:#333;}#mermaid-svg-bLBGonJR5zVHmYWy div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-bLBGonJR5zVHmYWy .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-bLBGonJR5zVHmYWy rect.text{fill:none;stroke-width:0;}#mermaid-svg-bLBGonJR5zVHmYWy .icon-shape,#mermaid-svg-bLBGonJR5zVHmYWy .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-bLBGonJR5zVHmYWy .icon-shape p,#mermaid-svg-bLBGonJR5zVHmYWy .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-bLBGonJR5zVHmYWy .icon-shape .label rect,#mermaid-svg-bLBGonJR5zVHmYWy .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-bLBGonJR5zVHmYWy .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-bLBGonJR5zVHmYWy .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-bLBGonJR5zVHmYWy :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} surfaces 层(薄交付面)
security 层(安全领域)
tools 工具
adapters 适配器
policies 证据/定级策略
playbooks 剧本
schemas 数据模型
rag 知识检索
fake 合成数据源
runtime 层(编排运行时)
graph 状态图与节点
state 状态契约
events 事件协议
approvals 审批门
llm 模型 profile
checkpoint 检查点
FastAPI + SSE
Webhook 接入
飞书投递
web/ React 工作台
分层的核心约束有两条:
-
runtime永远不 importsurfaces。运行时不知道自己会被 HTTP、Webhook 还是脚本调用,这样同一个图可以被四种入口复用; -
surfaces保持很薄,不允许写业务逻辑。HTTP handler、Webhook、飞书只负责协议转换和投递,所有判断都在 runtime 和 security 里完成。
各层的关键目录与职责可以对照下表:
| 包 | 关键模块 | 职责 |
|---|---|---|
runtime |
graph/、state/、events/、approvals/、llm/、checkpoint/ |
图编排、状态、事件、人工审核、模型构造、持久化 |
security |
tools/、adapters/、policies/、playbooks/、schemas/、rag/、fake/ |
工具实现、外部连接、阈值策略、提示词剧本、数据 schema、离线模拟 |
surfaces |
api/、web/(SSE)、webhook/、feishu/ |
接口、流式协议、告警接入、消息投递 |
第 3 章 编排设计:把自治边界画进图里
这一章是整个项目的核心。我会从主工作流讲起,再逐个拆解状态契约、代码路由、并行扇出、工具预算和人工审批。
3.1 主工作流:11 个节点和一张有环的图
图的构建位于 src/runtime/graph/investigation.py 的 build_investigation_graph()。整张图一共注册了 11 个节点:
#mermaid-svg-fmapwfGVwdO48EQj{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-fmapwfGVwdO48EQj .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-fmapwfGVwdO48EQj .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-fmapwfGVwdO48EQj .error-icon{fill:#552222;}#mermaid-svg-fmapwfGVwdO48EQj .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-fmapwfGVwdO48EQj .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-fmapwfGVwdO48EQj .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-fmapwfGVwdO48EQj .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-fmapwfGVwdO48EQj .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-fmapwfGVwdO48EQj .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-fmapwfGVwdO48EQj .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-fmapwfGVwdO48EQj .marker{fill:#333333;stroke:#333333;}#mermaid-svg-fmapwfGVwdO48EQj .marker.cross{stroke:#333333;}#mermaid-svg-fmapwfGVwdO48EQj svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-fmapwfGVwdO48EQj p{margin:0;}#mermaid-svg-fmapwfGVwdO48EQj .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-fmapwfGVwdO48EQj .cluster-label text{fill:#333;}#mermaid-svg-fmapwfGVwdO48EQj .cluster-label span{color:#333;}#mermaid-svg-fmapwfGVwdO48EQj .cluster-label span p{background-color:transparent;}#mermaid-svg-fmapwfGVwdO48EQj .label text,#mermaid-svg-fmapwfGVwdO48EQj span{fill:#333;color:#333;}#mermaid-svg-fmapwfGVwdO48EQj .node rect,#mermaid-svg-fmapwfGVwdO48EQj .node circle,#mermaid-svg-fmapwfGVwdO48EQj .node ellipse,#mermaid-svg-fmapwfGVwdO48EQj .node polygon,#mermaid-svg-fmapwfGVwdO48EQj .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-fmapwfGVwdO48EQj .rough-node .label text,#mermaid-svg-fmapwfGVwdO48EQj .node .label text,#mermaid-svg-fmapwfGVwdO48EQj .image-shape .label,#mermaid-svg-fmapwfGVwdO48EQj .icon-shape .label{text-anchor:middle;}#mermaid-svg-fmapwfGVwdO48EQj .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-fmapwfGVwdO48EQj .rough-node .label,#mermaid-svg-fmapwfGVwdO48EQj .node .label,#mermaid-svg-fmapwfGVwdO48EQj .image-shape .label,#mermaid-svg-fmapwfGVwdO48EQj .icon-shape .label{text-align:center;}#mermaid-svg-fmapwfGVwdO48EQj .node.clickable{cursor:pointer;}#mermaid-svg-fmapwfGVwdO48EQj .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-fmapwfGVwdO48EQj .arrowheadPath{fill:#333333;}#mermaid-svg-fmapwfGVwdO48EQj .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-fmapwfGVwdO48EQj .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-fmapwfGVwdO48EQj .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-fmapwfGVwdO48EQj .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-fmapwfGVwdO48EQj .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-fmapwfGVwdO48EQj .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-fmapwfGVwdO48EQj .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-fmapwfGVwdO48EQj .cluster text{fill:#333;}#mermaid-svg-fmapwfGVwdO48EQj .cluster span{color:#333;}#mermaid-svg-fmapwfGVwdO48EQj div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-fmapwfGVwdO48EQj .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-fmapwfGVwdO48EQj rect.text{fill:none;stroke-width:0;}#mermaid-svg-fmapwfGVwdO48EQj .icon-shape,#mermaid-svg-fmapwfGVwdO48EQj .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-fmapwfGVwdO48EQj .icon-shape p,#mermaid-svg-fmapwfGVwdO48EQj .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-fmapwfGVwdO48EQj .icon-shape .label rect,#mermaid-svg-fmapwfGVwdO48EQj .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-fmapwfGVwdO48EQj .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-fmapwfGVwdO48EQj .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-fmapwfGVwdO48EQj :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 缺日志发现
缺主机发现且能力允许
缺防护发现且能力允许
Send 逐资产
Send 逐资产
齐备且有 critical
齐备且无需审核
同意 resume
拒绝
START
asset_resolution
资产解析
supervisor
代码选路 + 模型写任务
log_detective
ES + 知识检索
dispatch_machine_checks
dispatch_security_checks
machine_check
machine_rollup
security_guard
security_rollup
approval_gate
interrupt
reporter
END failed
END completed
这张图有几个刻意的设计:
-
图是有环的:三个 worker 执行完都回到 supervisor,由 supervisor 重新评估后再决定下一步,而不是一条直线走到底;
-
并行只发生在「同类检查的多个资产之间」,三个专家阶段本身按「日志 → 主机 → 防护」的固定优先级串行推进,不会全程并行;
-
部分阶段可被跳过:比如只给了一个域名、解析不出主机资产时,主机检查会因能力不允许而跳过,不会让图卡死;
-
安全兜底 :supervisor 的路由函数如果读到非法的
next_node,默认回到 reporter,而不是让图崩溃。
3.2 状态契约:让每个节点都依赖同一份显式状态
图的「记忆」是 src/runtime/state/session.py 里的 SessionState,它是一个 TypedDict。我没有用隐式的对话上下文去隐式传递信息,而是把所有需要跨节点共享的数据都显式声明出来:
| 状态字段 | 类型 | 合并方式(reducer) | 用途 |
|---|---|---|---|
messages |
消息列表 | add_messages |
对话与工具消息历史 |
findings |
字典 | 浅合并 _merge_dict |
会话级的 logs /machine/security 结论 |
asset_findings |
嵌套字典 | 嵌套合并 _merge_nested_dict |
每个资产各自的证据,按 asset_id 隔离 |
artifacts |
列表 | operator.add 追加 |
最终报告等产物 |
next_node / agent_task |
字符串 | 覆盖 | 代码选路结果 / 模型生成的任务描述 |
status |
枚举 | 覆盖 | running / completed / failed / waiting_approval |
target_type / capability_scope / inspection_scope 等 |
多种 | 覆盖 | 资产解析产物与能力范围 |
reducer 的设计是并行安全的关键。以嵌套合并函数为例,它保证多个资产并行写回时,各自的结果不会互相覆盖:
def _merge_nested_dict(a, b):
"""合并嵌套的资产发现,保留之前 worker 的输出。"""
merged = dict(a)
for outer_key, inner_value in b.items():
existing = merged.get(outer_key, {})
if isinstance(existing, dict) and isinstance(inner_value, dict):
merged[outer_key] = {**existing, **inner_value}
else:
merged[outer_key] = inner_value
return merged
同时图还显式声明了输入 / 输出 schema(InvestigationInput / InvestigationOutput),外部调用方只能传入 messages、target、session_id 和可选的预置 findings,拿到的输出也只暴露约定字段,内部状态不会泄漏到图外。
3.3 设计决策一:代码控制路由,模型只写任务描述
这是整个项目最重要的自治边界。常见做法是让 LLM 自己决定下一步去哪个节点,但在安全场景下,「该查哪一路、能不能跳过」是不能交给模型自由发挥的。
我的做法是把 supervisor 拆成两个职责:
-
代码负责选路:根据「已有的 findings」和「资产能力范围」,确定性地决定下一个 worker;
-
模型负责写任务描述:在选路确定之后,让 LLM 生成一句带上下文的具体任务指令,告诉 worker 该聚焦什么。
核心选路逻辑(supervisor.py)是纯代码、可单测的:
_DISPATCH_ORDER = ["log_detective", "machine_check", "security_guard"]
def _eligible_workers(capability_scope):
# 日志永远可用;主机/防护取决于资产能力
scope = capability_scope or {}
allowed = {"log_detective"}
if scope.get("machine"):
allowed.add("machine_check")
if scope.get("security"):
allowed.add("security_guard")
return frozenset(allowed)
def _next_worker(findings, eligible_workers):
# 按固定顺序,找到第一个「必需发现尚未齐备」的 worker
for worker in _DISPATCH_ORDER:
if worker not in eligible_workers:
continue
required = _WORKER_FINDINGS[worker]
if not required.issubset(findings.keys()):
return worker
return "reporter"
模型的输出被严格约束成一个 Pydantic 模型,只有一个字段,从结构上杜绝了它篡改路由:
class AgentTaskDecision(BaseModel):
"""LLM 输出:给被选中 worker 的一句上下文任务指令。"""
agent_task: str = Field(
description="Specific, one-sentence task instruction for the worker node. "
"Include the target name and what aspect to focus on."
)
调用时用 with_structured_output(..., method="function_calling") 强制结构化输出。这样即便模型出现幻觉,它能影响的也只是「任务怎么描述」,而无法改变「去哪个节点」。
当所有 worker 的必需发现都齐备时,supervisor 还会在代码里检查是否需要人工审批(见 3.6),决定走 reporter 还是 approval_gate。整个决策流程如下:
#mermaid-svg-BKXslpHDJ1Veqk0E{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-BKXslpHDJ1Veqk0E .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-BKXslpHDJ1Veqk0E .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-BKXslpHDJ1Veqk0E .error-icon{fill:#552222;}#mermaid-svg-BKXslpHDJ1Veqk0E .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-BKXslpHDJ1Veqk0E .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-BKXslpHDJ1Veqk0E .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-BKXslpHDJ1Veqk0E .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-BKXslpHDJ1Veqk0E .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-BKXslpHDJ1Veqk0E .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-BKXslpHDJ1Veqk0E .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-BKXslpHDJ1Veqk0E .marker{fill:#333333;stroke:#333333;}#mermaid-svg-BKXslpHDJ1Veqk0E .marker.cross{stroke:#333333;}#mermaid-svg-BKXslpHDJ1Veqk0E svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-BKXslpHDJ1Veqk0E p{margin:0;}#mermaid-svg-BKXslpHDJ1Veqk0E .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-BKXslpHDJ1Veqk0E .cluster-label text{fill:#333;}#mermaid-svg-BKXslpHDJ1Veqk0E .cluster-label span{color:#333;}#mermaid-svg-BKXslpHDJ1Veqk0E .cluster-label span p{background-color:transparent;}#mermaid-svg-BKXslpHDJ1Veqk0E .label text,#mermaid-svg-BKXslpHDJ1Veqk0E span{fill:#333;color:#333;}#mermaid-svg-BKXslpHDJ1Veqk0E .node rect,#mermaid-svg-BKXslpHDJ1Veqk0E .node circle,#mermaid-svg-BKXslpHDJ1Veqk0E .node ellipse,#mermaid-svg-BKXslpHDJ1Veqk0E .node polygon,#mermaid-svg-BKXslpHDJ1Veqk0E .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-BKXslpHDJ1Veqk0E .rough-node .label text,#mermaid-svg-BKXslpHDJ1Veqk0E .node .label text,#mermaid-svg-BKXslpHDJ1Veqk0E .image-shape .label,#mermaid-svg-BKXslpHDJ1Veqk0E .icon-shape .label{text-anchor:middle;}#mermaid-svg-BKXslpHDJ1Veqk0E .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-BKXslpHDJ1Veqk0E .rough-node .label,#mermaid-svg-BKXslpHDJ1Veqk0E .node .label,#mermaid-svg-BKXslpHDJ1Veqk0E .image-shape .label,#mermaid-svg-BKXslpHDJ1Veqk0E .icon-shape .label{text-align:center;}#mermaid-svg-BKXslpHDJ1Veqk0E .node.clickable{cursor:pointer;}#mermaid-svg-BKXslpHDJ1Veqk0E .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-BKXslpHDJ1Veqk0E .arrowheadPath{fill:#333333;}#mermaid-svg-BKXslpHDJ1Veqk0E .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-BKXslpHDJ1Veqk0E .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-BKXslpHDJ1Veqk0E .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-BKXslpHDJ1Veqk0E .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-BKXslpHDJ1Veqk0E .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-BKXslpHDJ1Veqk0E .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-BKXslpHDJ1Veqk0E .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-BKXslpHDJ1Veqk0E .cluster text{fill:#333;}#mermaid-svg-BKXslpHDJ1Veqk0E .cluster span{color:#333;}#mermaid-svg-BKXslpHDJ1Veqk0E div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-BKXslpHDJ1Veqk0E .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-BKXslpHDJ1Veqk0E rect.text{fill:none;stroke-width:0;}#mermaid-svg-BKXslpHDJ1Veqk0E .icon-shape,#mermaid-svg-BKXslpHDJ1Veqk0E .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-BKXslpHDJ1Veqk0E .icon-shape p,#mermaid-svg-BKXslpHDJ1Veqk0E .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-BKXslpHDJ1Veqk0E .icon-shape .label rect,#mermaid-svg-BKXslpHDJ1Veqk0E .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-BKXslpHDJ1Veqk0E .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-BKXslpHDJ1Veqk0E .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-BKXslpHDJ1Veqk0E :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否,有 worker
是
是
否
supervisor 读取状态
代码:计算合格 worker 集合
_eligible_workers
代码:按固定顺序找缺证据的 worker
_next_worker
所有必需发现
是否齐备?
模型:仅生成一句任务描述
AgentTaskDecision
写入 next_node + agent_task
进入对应 worker
代码策略:
存在 critical?
next_node = approval_gate
next_node = reporter
3.4 设计决策二:Send 扇出 + 资产隔离 + 汇总
当目标背后解析出多个资产(比如多台主机、多个产品 IP)时,同类检查需要在每个资产上各跑一遍。这里用 LangGraph 的 Send() 原语做并行扇出(fanout.py):
def route_machine_checks(state):
assets = select_assets(state, capability_scope_key="machine",
required_capabilities=("prom",))
if not assets:
# 没有可解析资产时,退回一次直接执行,避免图卡死
return [Send("machine_check", _worker_send_state(state))]
# 每个资产一个 Send,携带只含该资产的分片状态
return [
Send("machine_check", _worker_send_state(state, asset=asset))
for asset in assets
]
这里有两个关键细节:
-
每个 Send 只携带自己的资产分片 (
active_asset),并在任务里注明聚焦的资产 IP,worker 之间互不干扰; -
worker 先写
asset_findings,绝不直接竞争顶层findings的键。等同一类的所有资产都跑完,再由 rollup 节点统一汇总成会话级结论。
汇总节点(rollups.py)从各资产的发现里取最高风险、统计受影响资产和证据覆盖情况:
_RISK_ORDER = {"low": 0, "medium": 1, "high": 2, "critical": 3}
def _max_risk(entries):
if not entries:
return "low"
return max((e.get("risk_level", "low") for e in entries),
key=lambda r: _RISK_ORDER.get(r, -1))
「先隔离、再汇总」的完整数据流如下,它保证了并行写入的安全性和会话结论的一致性:
#mermaid-svg-MMqmrG6GXjq4Dpd0{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-MMqmrG6GXjq4Dpd0 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-MMqmrG6GXjq4Dpd0 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-MMqmrG6GXjq4Dpd0 .error-icon{fill:#552222;}#mermaid-svg-MMqmrG6GXjq4Dpd0 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-MMqmrG6GXjq4Dpd0 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-MMqmrG6GXjq4Dpd0 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-MMqmrG6GXjq4Dpd0 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-MMqmrG6GXjq4Dpd0 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-MMqmrG6GXjq4Dpd0 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-MMqmrG6GXjq4Dpd0 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-MMqmrG6GXjq4Dpd0 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-MMqmrG6GXjq4Dpd0 .marker.cross{stroke:#333333;}#mermaid-svg-MMqmrG6GXjq4Dpd0 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-MMqmrG6GXjq4Dpd0 p{margin:0;}#mermaid-svg-MMqmrG6GXjq4Dpd0 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-MMqmrG6GXjq4Dpd0 .cluster-label text{fill:#333;}#mermaid-svg-MMqmrG6GXjq4Dpd0 .cluster-label span{color:#333;}#mermaid-svg-MMqmrG6GXjq4Dpd0 .cluster-label span p{background-color:transparent;}#mermaid-svg-MMqmrG6GXjq4Dpd0 .label text,#mermaid-svg-MMqmrG6GXjq4Dpd0 span{fill:#333;color:#333;}#mermaid-svg-MMqmrG6GXjq4Dpd0 .node rect,#mermaid-svg-MMqmrG6GXjq4Dpd0 .node circle,#mermaid-svg-MMqmrG6GXjq4Dpd0 .node ellipse,#mermaid-svg-MMqmrG6GXjq4Dpd0 .node polygon,#mermaid-svg-MMqmrG6GXjq4Dpd0 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-MMqmrG6GXjq4Dpd0 .rough-node .label text,#mermaid-svg-MMqmrG6GXjq4Dpd0 .node .label text,#mermaid-svg-MMqmrG6GXjq4Dpd0 .image-shape .label,#mermaid-svg-MMqmrG6GXjq4Dpd0 .icon-shape .label{text-anchor:middle;}#mermaid-svg-MMqmrG6GXjq4Dpd0 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-MMqmrG6GXjq4Dpd0 .rough-node .label,#mermaid-svg-MMqmrG6GXjq4Dpd0 .node .label,#mermaid-svg-MMqmrG6GXjq4Dpd0 .image-shape .label,#mermaid-svg-MMqmrG6GXjq4Dpd0 .icon-shape .label{text-align:center;}#mermaid-svg-MMqmrG6GXjq4Dpd0 .node.clickable{cursor:pointer;}#mermaid-svg-MMqmrG6GXjq4Dpd0 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-MMqmrG6GXjq4Dpd0 .arrowheadPath{fill:#333333;}#mermaid-svg-MMqmrG6GXjq4Dpd0 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-MMqmrG6GXjq4Dpd0 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-MMqmrG6GXjq4Dpd0 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-MMqmrG6GXjq4Dpd0 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-MMqmrG6GXjq4Dpd0 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-MMqmrG6GXjq4Dpd0 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-MMqmrG6GXjq4Dpd0 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-MMqmrG6GXjq4Dpd0 .cluster text{fill:#333;}#mermaid-svg-MMqmrG6GXjq4Dpd0 .cluster span{color:#333;}#mermaid-svg-MMqmrG6GXjq4Dpd0 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-MMqmrG6GXjq4Dpd0 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-MMqmrG6GXjq4Dpd0 rect.text{fill:none;stroke-width:0;}#mermaid-svg-MMqmrG6GXjq4Dpd0 .icon-shape,#mermaid-svg-MMqmrG6GXjq4Dpd0 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-MMqmrG6GXjq4Dpd0 .icon-shape p,#mermaid-svg-MMqmrG6GXjq4Dpd0 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-MMqmrG6GXjq4Dpd0 .icon-shape .label rect,#mermaid-svg-MMqmrG6GXjq4Dpd0 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-MMqmrG6GXjq4Dpd0 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-MMqmrG6GXjq4Dpd0 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-MMqmrG6GXjq4Dpd0 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 扇出
dispatch 节点
Send 资产 A
Send 资产 B
Send 资产 C
worker 实例 A
worker 实例 B
worker 实例 C
asset_findings
按 asset_id 隔离
rollup 汇总节点
取最高风险 / 统计覆盖
顶层 findings
会话级单条结论
回到 supervisor
3.5 设计决策三:工具预算与循环防护
给了模型工具,就必须防止它失控:要么在一个没数据的目标上反复空转直到撞穿递归上限,要么重复调用同一个工具浪费预算。我在 _tool_tracing.py 里给所有工具包了一层 wrapper,做了三重防护。
第一重:硬工具预算。 每个 worker 的工具调用上限写在剧本(playbook)里,并通过递归限制强制生效。一次工具调用在 ReAct 里占 2 步(LLM 决策 + 工具执行),再加 1 步给最终回答:
_RECURSION_LIMIT = _PLAYBOOK.max_tool_calls * 2 + 1
第二重:空结果早停。 当连续 N 次工具调用都返回空 / 零结果时,wrapper 把结果替换成一个哨兵字符串,提示模型停止调用、总结现有发现:
_EARLY_STOP_TEMPLATE = (
"[EARLY_STOP: {n} consecutive empty results. "
"Insufficient data for this target. Stop calling tools and summarize findings so far.]"
)
第三重:重复调用循环防护。 如果同一个工具被连续调用且结果非空,超过阈值后同样替换成哨兵,防止模型把预算烧在无意义的重复调用上:
_LOOP_STOP_TEMPLATE = (
"[LOOP_STOP: same tool called {n} consecutive times. "
"Stop and summarize findings so far.]"
)
此外,即使 ReAct 最终还是撞穿了递归上限,外层也会捕获 GraphRecursionError 并返回 None,让 worker 走「部分发现」的优雅降级,而不是让整个调查图崩溃:
async def ainvoke_react_agent(agent, messages, *, recursion_limit, node, write, degrade_message):
try:
return await agent.ainvoke({"messages": messages},
config={"recursion_limit": recursion_limit})
except GraphRecursionError:
write(StatusEvent(node=node, message=degrade_message))
return None
工具调用的完整生命周期与三重防护位置如下:
#mermaid-svg-oWF7LlscBo75puEk{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-oWF7LlscBo75puEk .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-oWF7LlscBo75puEk .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-oWF7LlscBo75puEk .error-icon{fill:#552222;}#mermaid-svg-oWF7LlscBo75puEk .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-oWF7LlscBo75puEk .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-oWF7LlscBo75puEk .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-oWF7LlscBo75puEk .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-oWF7LlscBo75puEk .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-oWF7LlscBo75puEk .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-oWF7LlscBo75puEk .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-oWF7LlscBo75puEk .marker{fill:#333333;stroke:#333333;}#mermaid-svg-oWF7LlscBo75puEk .marker.cross{stroke:#333333;}#mermaid-svg-oWF7LlscBo75puEk svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-oWF7LlscBo75puEk p{margin:0;}#mermaid-svg-oWF7LlscBo75puEk .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-oWF7LlscBo75puEk .cluster-label text{fill:#333;}#mermaid-svg-oWF7LlscBo75puEk .cluster-label span{color:#333;}#mermaid-svg-oWF7LlscBo75puEk .cluster-label span p{background-color:transparent;}#mermaid-svg-oWF7LlscBo75puEk .label text,#mermaid-svg-oWF7LlscBo75puEk span{fill:#333;color:#333;}#mermaid-svg-oWF7LlscBo75puEk .node rect,#mermaid-svg-oWF7LlscBo75puEk .node circle,#mermaid-svg-oWF7LlscBo75puEk .node ellipse,#mermaid-svg-oWF7LlscBo75puEk .node polygon,#mermaid-svg-oWF7LlscBo75puEk .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-oWF7LlscBo75puEk .rough-node .label text,#mermaid-svg-oWF7LlscBo75puEk .node .label text,#mermaid-svg-oWF7LlscBo75puEk .image-shape .label,#mermaid-svg-oWF7LlscBo75puEk .icon-shape .label{text-anchor:middle;}#mermaid-svg-oWF7LlscBo75puEk .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-oWF7LlscBo75puEk .rough-node .label,#mermaid-svg-oWF7LlscBo75puEk .node .label,#mermaid-svg-oWF7LlscBo75puEk .image-shape .label,#mermaid-svg-oWF7LlscBo75puEk .icon-shape .label{text-align:center;}#mermaid-svg-oWF7LlscBo75puEk .node.clickable{cursor:pointer;}#mermaid-svg-oWF7LlscBo75puEk .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-oWF7LlscBo75puEk .arrowheadPath{fill:#333333;}#mermaid-svg-oWF7LlscBo75puEk .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-oWF7LlscBo75puEk .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-oWF7LlscBo75puEk .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-oWF7LlscBo75puEk .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-oWF7LlscBo75puEk .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-oWF7LlscBo75puEk .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-oWF7LlscBo75puEk .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-oWF7LlscBo75puEk .cluster text{fill:#333;}#mermaid-svg-oWF7LlscBo75puEk .cluster span{color:#333;}#mermaid-svg-oWF7LlscBo75puEk div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-oWF7LlscBo75puEk .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-oWF7LlscBo75puEk rect.text{fill:none;stroke-width:0;}#mermaid-svg-oWF7LlscBo75puEk .icon-shape,#mermaid-svg-oWF7LlscBo75puEk .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-oWF7LlscBo75puEk .icon-shape p,#mermaid-svg-oWF7LlscBo75puEk .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-oWF7LlscBo75puEk .icon-shape .label rect,#mermaid-svg-oWF7LlscBo75puEk .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-oWF7LlscBo75puEk .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-oWF7LlscBo75puEk .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-oWF7LlscBo75puEk :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 连续空结果达阈值
非空
达阈值
否
是
否
模型决定调用工具
traced wrapper
发出 ToolCallEvent
执行原始工具
原始结果先喂给 EvidenceCollector
(在任何哨兵替换之前)
结果是否为空?
替换为 EARLY_STOP 哨兵
同一工具是否连续重复?
替换为 LOOP_STOP 哨兵
保留真实结果
发出 ToolResultEvent 并返回给模型
是否撞穿递归上限?
捕获异常 → 返回 None → 部分发现降级
正常输出
3.6 设计决策四:critical 触发的人工审批门
安全系统绝不能自动执行封禁、重启这类高风险动作,严重事件也必须有人签字。我用 LangGraph 的 interrupt 语义实现了一个审批门(approvals/gate.py),位置在取证之后、报告之前。
它的执行模型是:
-
首次执行 :调用
interrupt()保存检查点后抛出中断,图被暂停,调用方收到暂停状态; -
恢复执行 :调用方发送
Command(resume={"approved": ..., "approver": ...}),interrupt()返回该载荷,节点据此决定继续还是终止。
核心代码:
async def approval_gate_node(state):
# 首次调用:在此暂停,下面的代码不会执行
decision: dict = interrupt({
"type": "approval_request",
"session_id": state.get("session_id", ""),
"findings": state.get("findings", {}),
})
# 恢复后才执行
approved = bool(decision.get("approved", False))
approver = str(decision.get("approver", "unknown"))
write(ApprovalDecisionEvent(session_id=..., approved=approved, approver=approver))
if not approved:
return {"status": "failed",
"error": f"Investigation rejected by approver '{approver}'"}
return {} # 同意:不改状态,继续走向 reporter
是否需要进入审批门,由代码策略根据结构化的 risk_level 字段判断,不做任何文本解析:
def requires_approval(self, findings):
if not self.hitl_required_for_critical:
return False
for finding_dict in findings.values():
if isinstance(finding_dict, dict) and finding_dict.get("risk_level") == "critical":
return True
return False
审批门的完整交互时序如下,注意 supervisor 会先发出 approval_request 事件,图才真正暂停,前端因此能先看到请求再弹窗:
Reporter ApprovalGate Supervisor 调查图 FastAPI 用户/前端 Reporter ApprovalGate Supervisor 调查图 FastAPI 用户/前端 #mermaid-svg-hob5CUTBPAt8QXxJ{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-hob5CUTBPAt8QXxJ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-hob5CUTBPAt8QXxJ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-hob5CUTBPAt8QXxJ .error-icon{fill:#552222;}#mermaid-svg-hob5CUTBPAt8QXxJ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-hob5CUTBPAt8QXxJ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-hob5CUTBPAt8QXxJ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-hob5CUTBPAt8QXxJ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-hob5CUTBPAt8QXxJ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-hob5CUTBPAt8QXxJ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-hob5CUTBPAt8QXxJ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-hob5CUTBPAt8QXxJ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-hob5CUTBPAt8QXxJ .marker.cross{stroke:#333333;}#mermaid-svg-hob5CUTBPAt8QXxJ svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-hob5CUTBPAt8QXxJ p{margin:0;}#mermaid-svg-hob5CUTBPAt8QXxJ .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-hob5CUTBPAt8QXxJ text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-hob5CUTBPAt8QXxJ .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-hob5CUTBPAt8QXxJ .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-hob5CUTBPAt8QXxJ .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-hob5CUTBPAt8QXxJ .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-hob5CUTBPAt8QXxJ #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-hob5CUTBPAt8QXxJ .sequenceNumber{fill:white;}#mermaid-svg-hob5CUTBPAt8QXxJ #sequencenumber{fill:#333;}#mermaid-svg-hob5CUTBPAt8QXxJ #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-hob5CUTBPAt8QXxJ .messageText{fill:#333;stroke:none;}#mermaid-svg-hob5CUTBPAt8QXxJ .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-hob5CUTBPAt8QXxJ .labelText,#mermaid-svg-hob5CUTBPAt8QXxJ .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-hob5CUTBPAt8QXxJ .loopText,#mermaid-svg-hob5CUTBPAt8QXxJ .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-hob5CUTBPAt8QXxJ .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-hob5CUTBPAt8QXxJ .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-hob5CUTBPAt8QXxJ .noteText,#mermaid-svg-hob5CUTBPAt8QXxJ .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-hob5CUTBPAt8QXxJ .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-hob5CUTBPAt8QXxJ .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-hob5CUTBPAt8QXxJ .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-hob5CUTBPAt8QXxJ .actorPopupMenu{position:absolute;}#mermaid-svg-hob5CUTBPAt8QXxJ .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-hob5CUTBPAt8QXxJ .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-hob5CUTBPAt8QXxJ .actor-man circle,#mermaid-svg-hob5CUTBPAt8QXxJ line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-hob5CUTBPAt8QXxJ :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 审核全部 findings 快照 alt 同意 拒绝 POST /investigation/stream astream 启动图 取证齐备,代码策略命中 critical 发出 ApprovalRequestEvent 路由到 approval_gate interrupt() 保存检查点并暂停 SSE 推送 approval_request + 弹窗 POST /investigation/resume approved=true Command(resume=...) 继续生成报告 artifact / final 事件 POST /investigation/resume approved=false Command(resume=...) status=failed,图终止
第 4 章 核心代码说明:确定性是怎么落地的
这一章讲清楚三个让系统「可信」而不是「靠模型自觉」的关键机制:确定性证据、代码定级、以及报告的多层兜底。
4.1 EvidenceCollector:从真实工具结果里提取数值
历史上有个真实的坑:worker 的关键数值(如 5xx 错误率、CPU 占用)原本是让一个 LLM 抽取器去读自由文本分析,结果模型经常漏字段、把数字写成散文、或者直接留空 ,导致定级函数收到 None,最终一切都被默认成 low------ 这在安全场景里是致命的「假健康」。
我的解法是 EvidenceCollector(policies/extraction.py):在每次被追踪的工具返回时,直接从原始工具结果里解析数值,完全绕开模型回忆。而且它在 3.5 提到的哨兵替换之前就执行,所以即使后来 ReAct 撞穿预算,真实遥测数据也已经落袋。
以日志维度为例,它优先从状态码分布工具取权威比例,取不到再用 QPS 趋势做确定性兜底:
def extract_log_evidence(messages):
out, qps_sum, trend5, trend4 = {}, 0, 0, 0
observed_trends = set()
for name, result in _iter_observations(messages):
if not isinstance(result, dict):
continue
if name == "query_status_code_distribution":
cur = result.get("current") or []
r5, r4, total = _status_bucket_share(cur)
if total > 0:
# 权威来源:单次响应内比例守恒,不受模型选窗影响
return {"rate_5xx": r5, "rate_4xx": r4, "total_requests": total}
if name == "query_es_qps_trend":
qps_sum += sum(_as_int(b.get("count")) or 0
for b in (result.get("trend") or []))
# ... 累积 5xx / 4xx 趋势计数
if qps_sum > 0: # 兜底:用 QPS 总量当分母
if "5xx" in observed_trends:
out["rate_5xx"] = round(trend5 / qps_sum, 6)
# ...
return out
数值还会做统一归一化,比如把 '87.3%'、87.3、0.873 都规范成 [0,1] 区间的小数,与 findings schema 对齐。同时每个 worker 调用都新建一个 collector,不做模块级单例,避免跨会话污染。
4.2 EvidencePolicy:阈值在代码里,不在提示词里
所有「什么数值算异常」的阈值,都集中在 EvidencePolicy(policies/evidence.py)这个不可变 dataclass 里。提示词可以引用这些数值帮助模型理解,但绝不能成为阈值的唯一事实来源。
部分关键阈值:
| 维度 | 阈值 | 默认值 | 含义 |
|---|---|---|---|
| 主机 | high_cpu_pct |
0.80 | CPU 超过 80% 视为承压 |
| 主机 | high_memory_pct |
0.85 | 内存超过 85% 视为承压 |
| 主机 | high_tcp_connections |
10,000 | TCP 连接数超过 1 万视为高负载 |
| 日志 | high_error_rate_pct |
0.10 | 5xx 比例超过 10% 标记异常 |
| 日志 | min_request_count_for_analysis |
100 | 请求量低于 100 不下结论 |
| CC | cc_attack_pps_threshold |
2,000 | 输入 PPS 超过该值才标记 CC 事件 |
| DDoS | ddos_attack_kbps_threshold |
1,000,000 | 流量达到该 kbps(约 1 Gbps)标记 DDoS |
风险定级是纯函数、规则透明可审计。以日志和主机为例:
def classify_logs(self, findings):
rate_5xx = _normalize_ratio(findings.get("rate_5xx"))
rate_4xx = _normalize_ratio(findings.get("rate_4xx"))
if rate_5xx >= 0.50 or rate_4xx >= 0.80:
return "critical"
if rate_5xx >= self.high_error_rate_pct or rate_4xx >= 0.50:
return "high"
if rate_5xx > 0 or rate_4xx > 0.20:
return "medium"
return "low"
def classify_machine(self, findings):
if _all_machine_metrics_missing(findings):
# 没有遥测不等于健康,当作可观测性缺口处理
return "medium"
cpu, memory = _normalize_ratio(findings.get("cpu_pct")), _normalize_ratio(findings.get("memory_pct"))
tcp = findings.get("tcp_established") or 0
if cpu >= 0.95 or memory >= 0.95:
return "critical"
if cpu >= self.high_cpu_pct or memory >= self.high_memory_pct or tcp >= self.high_tcp_connections:
return "high"
if cpu >= 0.60 or memory >= 0.70:
return "medium"
return "low"
注意 classify_machine 里那条特别的规则:当所有指标都缺失时返回 medium而不是 low。这是有意为之 ------「没有数据」是可观测性缺口,必须暴露出来,而不是被粉饰成健康。
在 worker 节点里,确定性证据会覆盖模型抽取的同名字段,最后由代码统一重算风险等级:
findings_dict = findings.model_dump()
# 确定性遥测覆盖模型抽取的同名字段
for key, value in evidence.items():
if value is not None:
findings_dict[key] = value
findings_dict["evidence_status"] = collector.evidence_status()
findings_dict["risk_level"] = default_policy().classify_machine(findings_dict)
模型字段与确定性证据的覆盖关系如下:
#mermaid-svg-T3kXAQIZrhEKORal{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-T3kXAQIZrhEKORal .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-T3kXAQIZrhEKORal .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-T3kXAQIZrhEKORal .error-icon{fill:#552222;}#mermaid-svg-T3kXAQIZrhEKORal .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-T3kXAQIZrhEKORal .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-T3kXAQIZrhEKORal .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-T3kXAQIZrhEKORal .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-T3kXAQIZrhEKORal .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-T3kXAQIZrhEKORal .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-T3kXAQIZrhEKORal .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-T3kXAQIZrhEKORal .marker{fill:#333333;stroke:#333333;}#mermaid-svg-T3kXAQIZrhEKORal .marker.cross{stroke:#333333;}#mermaid-svg-T3kXAQIZrhEKORal svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-T3kXAQIZrhEKORal p{margin:0;}#mermaid-svg-T3kXAQIZrhEKORal .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-T3kXAQIZrhEKORal .cluster-label text{fill:#333;}#mermaid-svg-T3kXAQIZrhEKORal .cluster-label span{color:#333;}#mermaid-svg-T3kXAQIZrhEKORal .cluster-label span p{background-color:transparent;}#mermaid-svg-T3kXAQIZrhEKORal .label text,#mermaid-svg-T3kXAQIZrhEKORal span{fill:#333;color:#333;}#mermaid-svg-T3kXAQIZrhEKORal .node rect,#mermaid-svg-T3kXAQIZrhEKORal .node circle,#mermaid-svg-T3kXAQIZrhEKORal .node ellipse,#mermaid-svg-T3kXAQIZrhEKORal .node polygon,#mermaid-svg-T3kXAQIZrhEKORal .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-T3kXAQIZrhEKORal .rough-node .label text,#mermaid-svg-T3kXAQIZrhEKORal .node .label text,#mermaid-svg-T3kXAQIZrhEKORal .image-shape .label,#mermaid-svg-T3kXAQIZrhEKORal .icon-shape .label{text-anchor:middle;}#mermaid-svg-T3kXAQIZrhEKORal .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-T3kXAQIZrhEKORal .rough-node .label,#mermaid-svg-T3kXAQIZrhEKORal .node .label,#mermaid-svg-T3kXAQIZrhEKORal .image-shape .label,#mermaid-svg-T3kXAQIZrhEKORal .icon-shape .label{text-align:center;}#mermaid-svg-T3kXAQIZrhEKORal .node.clickable{cursor:pointer;}#mermaid-svg-T3kXAQIZrhEKORal .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-T3kXAQIZrhEKORal .arrowheadPath{fill:#333333;}#mermaid-svg-T3kXAQIZrhEKORal .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-T3kXAQIZrhEKORal .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-T3kXAQIZrhEKORal .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-T3kXAQIZrhEKORal .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-T3kXAQIZrhEKORal .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-T3kXAQIZrhEKORal .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-T3kXAQIZrhEKORal .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-T3kXAQIZrhEKORal .cluster text{fill:#333;}#mermaid-svg-T3kXAQIZrhEKORal .cluster span{color:#333;}#mermaid-svg-T3kXAQIZrhEKORal div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-T3kXAQIZrhEKORal .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-T3kXAQIZrhEKORal rect.text{fill:none;stroke-width:0;}#mermaid-svg-T3kXAQIZrhEKORal .icon-shape,#mermaid-svg-T3kXAQIZrhEKORal .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-T3kXAQIZrhEKORal .icon-shape p,#mermaid-svg-T3kXAQIZrhEKORal .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-T3kXAQIZrhEKORal .icon-shape .label rect,#mermaid-svg-T3kXAQIZrhEKORal .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-T3kXAQIZrhEKORal .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-T3kXAQIZrhEKORal .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-T3kXAQIZrhEKORal :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 工具原始返回
EvidenceCollector
确定性提取
ReAct 分析文本
LLM 抽取器
结构化字段
合并:确定性数值优先
非空即覆盖
EvidencePolicy
代码重算 risk_level
最终 findings
4.3 Reporter:三层提取阶梯 + 确定性兜底报告
报告节点(reporter.py)要解决的问题是:模型的结构化输出在不同供应商 / 中转端点上稳定性差异很大,不能因为一种方式失败就让整个调查没有报告。我设计了一个三层提取阶梯,每层失败就向下一层落:
#mermaid-svg-eYeq53r11T9YBlxX{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-eYeq53r11T9YBlxX .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-eYeq53r11T9YBlxX .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-eYeq53r11T9YBlxX .error-icon{fill:#552222;}#mermaid-svg-eYeq53r11T9YBlxX .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-eYeq53r11T9YBlxX .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-eYeq53r11T9YBlxX .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-eYeq53r11T9YBlxX .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-eYeq53r11T9YBlxX .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-eYeq53r11T9YBlxX .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-eYeq53r11T9YBlxX .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-eYeq53r11T9YBlxX .marker{fill:#333333;stroke:#333333;}#mermaid-svg-eYeq53r11T9YBlxX .marker.cross{stroke:#333333;}#mermaid-svg-eYeq53r11T9YBlxX svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-eYeq53r11T9YBlxX p{margin:0;}#mermaid-svg-eYeq53r11T9YBlxX .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-eYeq53r11T9YBlxX .cluster-label text{fill:#333;}#mermaid-svg-eYeq53r11T9YBlxX .cluster-label span{color:#333;}#mermaid-svg-eYeq53r11T9YBlxX .cluster-label span p{background-color:transparent;}#mermaid-svg-eYeq53r11T9YBlxX .label text,#mermaid-svg-eYeq53r11T9YBlxX span{fill:#333;color:#333;}#mermaid-svg-eYeq53r11T9YBlxX .node rect,#mermaid-svg-eYeq53r11T9YBlxX .node circle,#mermaid-svg-eYeq53r11T9YBlxX .node ellipse,#mermaid-svg-eYeq53r11T9YBlxX .node polygon,#mermaid-svg-eYeq53r11T9YBlxX .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-eYeq53r11T9YBlxX .rough-node .label text,#mermaid-svg-eYeq53r11T9YBlxX .node .label text,#mermaid-svg-eYeq53r11T9YBlxX .image-shape .label,#mermaid-svg-eYeq53r11T9YBlxX .icon-shape .label{text-anchor:middle;}#mermaid-svg-eYeq53r11T9YBlxX .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-eYeq53r11T9YBlxX .rough-node .label,#mermaid-svg-eYeq53r11T9YBlxX .node .label,#mermaid-svg-eYeq53r11T9YBlxX .image-shape .label,#mermaid-svg-eYeq53r11T9YBlxX .icon-shape .label{text-align:center;}#mermaid-svg-eYeq53r11T9YBlxX .node.clickable{cursor:pointer;}#mermaid-svg-eYeq53r11T9YBlxX .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-eYeq53r11T9YBlxX .arrowheadPath{fill:#333333;}#mermaid-svg-eYeq53r11T9YBlxX .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-eYeq53r11T9YBlxX .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-eYeq53r11T9YBlxX .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-eYeq53r11T9YBlxX .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-eYeq53r11T9YBlxX .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-eYeq53r11T9YBlxX .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-eYeq53r11T9YBlxX .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-eYeq53r11T9YBlxX .cluster text{fill:#333;}#mermaid-svg-eYeq53r11T9YBlxX .cluster span{color:#333;}#mermaid-svg-eYeq53r11T9YBlxX div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-eYeq53r11T9YBlxX .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-eYeq53r11T9YBlxX rect.text{fill:none;stroke-width:0;}#mermaid-svg-eYeq53r11T9YBlxX .icon-shape,#mermaid-svg-eYeq53r11T9YBlxX .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-eYeq53r11T9YBlxX .icon-shape p,#mermaid-svg-eYeq53r11T9YBlxX .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-eYeq53r11T9YBlxX .icon-shape .label rect,#mermaid-svg-eYeq53r11T9YBlxX .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-eYeq53r11T9YBlxX .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-eYeq53r11T9YBlxX .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-eYeq53r11T9YBlxX :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否/异常
是
否/异常
是
否
Reporter 调用模型
L1 function_calling
原生函数结构化输出
得到真实内容?
InvestigationReport
L2 json_mode
response_format=json_object
得到真实内容?
L3 原始文本
正则隔离 JSON → pydantic 校验
得到真实内容?
确定性兜底报告
_deterministic_report
这里有个容易被忽略的细节:schema 里每个字符串字段都有默认占位值(如 [requires analysis]),某些端点会返回一个语法合法但语义全空 的 JSON,它能通过 pydantic 校验却会产出误导性报告。所以我用 _has_real_content() 专门识别这种空壳,把它当作失败处理、继续向下一层落:
def _has_real_content(report):
placeholders = {"", None, "[requires analysis]", "unknown",
"insufficient data", "not collected"}
def _is_placeholder(v):
return v is None or str(v).strip() in placeholders
# 两个用户可见的头条字段都占位,即判定为空壳
return not (_is_placeholder(report.summary) and
_is_placeholder(report.recommendations))
如果三层都失败,最后的兜底是 _deterministic_report():它只用已收集的事实拼出一份真实报告,绝不编造。根因和建议都由确定性规则生成,例如:
# 根因:基于已观测数值的确定性规则
if isinstance(rate_5, (int, float)) and rate_5 >= 0.50:
root = f"源站 5xx 错误率异常偏高({rate_5:.0%}),指向应用层故障或上游依赖异常。"
elif isinstance(rate_4, (int, float)) and rate_4 >= 0.80:
root = f"4xx 错误率异常偏高({rate_4:.0%}),指向异常客户端或爬虫流量。"
elif mac_rl in ("critical", "high"):
root = "基础设施资源水位异常,需排查主机负载与进程状态。"
elif sec_rl in ("critical", "high"):
root = "检测到 CC/DDoS 攻击标记,需确认攻击目标与清洗策略。"
else:
root = "当前证据未能确定根因;请结合数据覆盖情况与原始观测人工复核。"
此外,即使模型成功写出了报告,总风险等级和告警状态仍然由代码根据分项 findings 重新计算并覆盖,模型无法通过文字抬高或压低最终定级;而且「证据不完整时不允许判低风险」这条规则也在代码里强制保证。
4.4 模型 Profile 抽象:业务代码不碰厂商 SDK
为了模型可替换、业务代码不直接依赖任何厂商 SDK,我定义了 6 个能力画像(llm/profiles.py)。业务代码只向 build_model(profile) 要一个模型,由它在调用时根据 settings 解析到具体供应商,且每次调用都新建实例、不做缓存单例:
| ModelProfile | temperature | max_tokens | 典型用途 |
|---|---|---|---|
FAST_CLASSIFIER |
0.0 | 1024 | supervisor 快速分类(用 flash 档模型) |
TOOL_REASONER |
0.0 | 4096 | 三个 worker 的多步工具推理 |
STRUCTURED_EXTRACTOR |
0.0 | 2048 | 结构化字段抽取 |
REPORT_WRITER |
0.3 | 4096 | 报告撰写(保留少量表达弹性) |
LONG_CONTEXT_ANALYST |
0.0 | 默认 | 长上下文分析 |
LOCAL_OFFLINE_MODEL |
0.0 | --- | 本地 / 离线端点 |
供应商切换在 build_model() 内部完成,支持 OpenAI 兼容、Anthropic 和本地端点。还把供应商特有的怪癖收敛在模型构造层,比如 DeepSeek 官方端点默认开启思考模式、会拒绝结构化输出所需的强制工具选择,就自动设置非思考模式:
if urlsplit(s.openai_base_url).hostname == "api.deepseek.com":
kwargs.setdefault("reasoning_effort", "none")
这样切换模型或供应商时,节点和业务代码一行都不用改。
4.5 fake /real 数据源接缝:让整个系统能离线演示
作品集必须能在没有企业内网的情况下完整跑通,所以我在数据层留了一个接缝:Settings.data_source 取 fake(默认)或 real。
-
fake模式下,通过security/fake/client.py的build_async_client()给 HTTP 客户端注入httpx.MockTransport,由fake/transport.py路由所有模拟返回,完全离线、无需 VPN;RAG 也短路到内置的小型知识库; -
real模式才会连接真实的 ES / Prometheus / 防护端点。
所有外部连接型适配器(adapter)都遵循统一的生命周期约束:实现 aclose()、首次使用时懒加载并在工厂作用域内缓存、绝不在模块级实例化。工具侧用 ToolBundle(adapters/lifecycle.py)配合 AsyncExitStack 管理资源,即使工具抛错或任务被取消,也能保证连接被正确关闭:
class ToolBundle(list):
def __init__(self):
super().__init__()
self._stack = AsyncExitStack()
def resource(self, factory, supplied=None):
cached = supplied
def get():
nonlocal cached
if cached is None:
cached = factory()
self._stack.push_async_callback(cached.aclose)
return cached
return get
检查点(checkpoint)也按环境切换:dev 用 SQLite(无需外部服务),其他环境用 PostgreSQL 做持久化,支持中断后的可靠恢复。
第 5 章 交付面:同一个运行时,四种入口
因为 runtime 不依赖任何 surface,同一个调查图可以被四种入口复用。它们都是薄适配层,不含业务判断:
#mermaid-svg-st45BITgchmuX4yR{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-st45BITgchmuX4yR .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-st45BITgchmuX4yR .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-st45BITgchmuX4yR .error-icon{fill:#552222;}#mermaid-svg-st45BITgchmuX4yR .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-st45BITgchmuX4yR .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-st45BITgchmuX4yR .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-st45BITgchmuX4yR .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-st45BITgchmuX4yR .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-st45BITgchmuX4yR .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-st45BITgchmuX4yR .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-st45BITgchmuX4yR .marker{fill:#333333;stroke:#333333;}#mermaid-svg-st45BITgchmuX4yR .marker.cross{stroke:#333333;}#mermaid-svg-st45BITgchmuX4yR svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-st45BITgchmuX4yR p{margin:0;}#mermaid-svg-st45BITgchmuX4yR .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-st45BITgchmuX4yR .cluster-label text{fill:#333;}#mermaid-svg-st45BITgchmuX4yR .cluster-label span{color:#333;}#mermaid-svg-st45BITgchmuX4yR .cluster-label span p{background-color:transparent;}#mermaid-svg-st45BITgchmuX4yR .label text,#mermaid-svg-st45BITgchmuX4yR span{fill:#333;color:#333;}#mermaid-svg-st45BITgchmuX4yR .node rect,#mermaid-svg-st45BITgchmuX4yR .node circle,#mermaid-svg-st45BITgchmuX4yR .node ellipse,#mermaid-svg-st45BITgchmuX4yR .node polygon,#mermaid-svg-st45BITgchmuX4yR .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-st45BITgchmuX4yR .rough-node .label text,#mermaid-svg-st45BITgchmuX4yR .node .label text,#mermaid-svg-st45BITgchmuX4yR .image-shape .label,#mermaid-svg-st45BITgchmuX4yR .icon-shape .label{text-anchor:middle;}#mermaid-svg-st45BITgchmuX4yR .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-st45BITgchmuX4yR .rough-node .label,#mermaid-svg-st45BITgchmuX4yR .node .label,#mermaid-svg-st45BITgchmuX4yR .image-shape .label,#mermaid-svg-st45BITgchmuX4yR .icon-shape .label{text-align:center;}#mermaid-svg-st45BITgchmuX4yR .node.clickable{cursor:pointer;}#mermaid-svg-st45BITgchmuX4yR .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-st45BITgchmuX4yR .arrowheadPath{fill:#333333;}#mermaid-svg-st45BITgchmuX4yR .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-st45BITgchmuX4yR .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-st45BITgchmuX4yR .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-st45BITgchmuX4yR .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-st45BITgchmuX4yR .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-st45BITgchmuX4yR .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-st45BITgchmuX4yR .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-st45BITgchmuX4yR .cluster text{fill:#333;}#mermaid-svg-st45BITgchmuX4yR .cluster span{color:#333;}#mermaid-svg-st45BITgchmuX4yR div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-st45BITgchmuX4yR .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-st45BITgchmuX4yR rect.text{fill:none;stroke-width:0;}#mermaid-svg-st45BITgchmuX4yR .icon-shape,#mermaid-svg-st45BITgchmuX4yR .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-st45BITgchmuX4yR .icon-shape p,#mermaid-svg-st45BITgchmuX4yR .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-st45BITgchmuX4yR .icon-shape .label rect,#mermaid-svg-st45BITgchmuX4yR .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-st45BITgchmuX4yR .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-st45BITgchmuX4yR .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-st45BITgchmuX4yR :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 同一个运行时
build_investigation_graph()
- get_checkpointer()
FastAPI
POST /investigation(同步 JSON)
SSE 流
POST /investigation/stream + /resume
Grafana Webhook
POST /webhooks/grafana(后台任务)
飞书机器人
报告卡片投递
React 调查工作台 :18230
5.1 HTTP / SSE 接口
后端一共暴露三个调查相关接口(surfaces/api/):
| 方法与路径 | 行为 |
|---|---|
POST /investigation |
非流式,图跑完返回完整输出 JSON |
POST /investigation/stream |
SSE 实时推送 8 种 RuntimeEvent,含子图内的工具调用事件 |
POST /investigation/resume |
在同一 checkpoint thread 上恢复被审批门暂停的会话 |
SSE 的线格式是标准的 data: <JSON>\n\n,由 surfaces/web/sse.py 把 RuntimeEvent 转成帧,转换层不引用任何 LangGraph 节点名,保持协议解耦。
5.2 Webhook 接入与去重
surfaces/webhook/ 接收 Grafana 告警:提取调查实体、做去重和并发闸门,然后用后台任务跑调查,并立即返回 200(Grafana 要求快速响应)。去重规则复刻了原系统的行为:
| 情况 | 处理 |
|---|---|
| 实体正在分析中 | 跳过 |
| 实体 6 小时内已分析过 | 跳过 |
| 实体 1 小时内分析失败 | 跳过(冷却后允许重试) |
| 并发分析数达到上限(默认 3) | 跳过 |
当前去重是基于 asyncio.Lock + dict 的单进程实现,并预留了 Redis 替换接口;分析中的状态还设有 10 分钟安全过期,防止进程崩溃后实体被永久锁死。
5.3 飞书投递与 React 工作台
-
飞书 :
feishu/delivery.py把InvestigationReport转成飞书交互卡片(markdown 卡片体),按告警状态着色,仅在配置了 webhook 时尽力投递,失败不阻塞主流程; -
React 工作台 :前端用 TypeScript 实现,通过
useInvestigationhook 管理调查 / 审批 / 页面状态,把 SSE 事件映射成阶段轨道、事件日志、发现面板、报告卡片和审批弹窗。节点名到语义的映射集中在web/src/lib/agui.ts,避免 UI 与图节点硬耦合(需要说明,它是内部适配器,并非完整的 AG-UI 线协议实现)。
第 6 章 验证与边界:能证明什么,不能声称什么
我把「诚实交代验证范围」当作这个作品集的一部分,而不是只展示光鲜的部分。
6.1 测试体系
测试全部离线运行,外部端点一律 Mock,不需要企业内网或真实模型调用。测试按三层组织:
#mermaid-svg-lwK1BrFrdPdINSwu{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-lwK1BrFrdPdINSwu .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-lwK1BrFrdPdINSwu .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-lwK1BrFrdPdINSwu .error-icon{fill:#552222;}#mermaid-svg-lwK1BrFrdPdINSwu .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-lwK1BrFrdPdINSwu .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-lwK1BrFrdPdINSwu .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-lwK1BrFrdPdINSwu .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-lwK1BrFrdPdINSwu .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-lwK1BrFrdPdINSwu .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-lwK1BrFrdPdINSwu .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-lwK1BrFrdPdINSwu .marker{fill:#333333;stroke:#333333;}#mermaid-svg-lwK1BrFrdPdINSwu .marker.cross{stroke:#333333;}#mermaid-svg-lwK1BrFrdPdINSwu svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-lwK1BrFrdPdINSwu p{margin:0;}#mermaid-svg-lwK1BrFrdPdINSwu .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-lwK1BrFrdPdINSwu .cluster-label text{fill:#333;}#mermaid-svg-lwK1BrFrdPdINSwu .cluster-label span{color:#333;}#mermaid-svg-lwK1BrFrdPdINSwu .cluster-label span p{background-color:transparent;}#mermaid-svg-lwK1BrFrdPdINSwu .label text,#mermaid-svg-lwK1BrFrdPdINSwu span{fill:#333;color:#333;}#mermaid-svg-lwK1BrFrdPdINSwu .node rect,#mermaid-svg-lwK1BrFrdPdINSwu .node circle,#mermaid-svg-lwK1BrFrdPdINSwu .node ellipse,#mermaid-svg-lwK1BrFrdPdINSwu .node polygon,#mermaid-svg-lwK1BrFrdPdINSwu .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-lwK1BrFrdPdINSwu .rough-node .label text,#mermaid-svg-lwK1BrFrdPdINSwu .node .label text,#mermaid-svg-lwK1BrFrdPdINSwu .image-shape .label,#mermaid-svg-lwK1BrFrdPdINSwu .icon-shape .label{text-anchor:middle;}#mermaid-svg-lwK1BrFrdPdINSwu .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-lwK1BrFrdPdINSwu .rough-node .label,#mermaid-svg-lwK1BrFrdPdINSwu .node .label,#mermaid-svg-lwK1BrFrdPdINSwu .image-shape .label,#mermaid-svg-lwK1BrFrdPdINSwu .icon-shape .label{text-align:center;}#mermaid-svg-lwK1BrFrdPdINSwu .node.clickable{cursor:pointer;}#mermaid-svg-lwK1BrFrdPdINSwu .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-lwK1BrFrdPdINSwu .arrowheadPath{fill:#333333;}#mermaid-svg-lwK1BrFrdPdINSwu .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-lwK1BrFrdPdINSwu .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-lwK1BrFrdPdINSwu .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-lwK1BrFrdPdINSwu .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-lwK1BrFrdPdINSwu .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-lwK1BrFrdPdINSwu .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-lwK1BrFrdPdINSwu .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-lwK1BrFrdPdINSwu .cluster text{fill:#333;}#mermaid-svg-lwK1BrFrdPdINSwu .cluster span{color:#333;}#mermaid-svg-lwK1BrFrdPdINSwu div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-lwK1BrFrdPdINSwu .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-lwK1BrFrdPdINSwu rect.text{fill:none;stroke-width:0;}#mermaid-svg-lwK1BrFrdPdINSwu .icon-shape,#mermaid-svg-lwK1BrFrdPdINSwu .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-lwK1BrFrdPdINSwu .icon-shape p,#mermaid-svg-lwK1BrFrdPdINSwu .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-lwK1BrFrdPdINSwu .icon-shape .label rect,#mermaid-svg-lwK1BrFrdPdINSwu .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-lwK1BrFrdPdINSwu .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-lwK1BrFrdPdINSwu .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-lwK1BrFrdPdINSwu :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 测试金字塔
单元测试 tests/unit
覆盖适配器、工具、策略、节点、事件等
回放测试 tests/replay
事故级流程与产物结构
集成测试 tests/integration
审批流、子图冒泡
| 测试层 | 关注点 | 代表性测试 |
|---|---|---|
| 单元测试 | 适配器、工具、证据策略、节点逻辑、SSE、去重 | test_tool_es.py、test_evidence_policy.py、test_supervisor_routing.py、test_hitl.py |
| 集成测试 | 跨节点的审批流程、子图事件冒泡 | test_approval_flow.py、test_subgraph_bubbling.py |
| 回放测试 | 预置 findings + Mock 模型下的完整流程与产物结构 | test_incident_replay.py |
此外前端有独立的事件回归测试(web/tests),并通过 tsc 严格类型检查与 Vite 构建;仓库配置了 GitHub Actions 在 CI 上运行后端测试与前端构建。
6.2 如实声明的当前边界
以下都是已知缺口,我没有把它们包装成已解决的能力:
-
没有自动处置:系统不会自动封禁、重启或修复,审批门只位于取证之后、报告之前,也不审核此前的每一次查询;
-
不是审计系统:工具调用虽通过唯一 ID 关联、可查看完整结果,但时间线没有长期存储、检索和访问控制,不等同于合规审计;
-
报告正文不保证全字段一致:模型生成的叙述和根因仍可能与证据存在出入,当前没有完整的跨字段一致性校验,需要结合原始证据复核;
-
部分前端仍识别节点名:尽管有集中语义映射,个别组件仍未完全解耦;
-
real 模式未验证 :
DATA_SOURCE=real需要真实系统和凭据,不在当前作品集完结条件内;Webhook / 飞书保留为接入示例,完整企业部署与 critical 审核运营流程未经验证; -
不宣称诊断准确率:事故回放部分预置 findings 并 Mock 模型,只能验证流程与产物结构,不能代表通用诊断效果或线上成绩。
第 7 章 复盘:我从这个项目里沉淀的设计原则
把整个项目做完,我最大的收获不是「学会了某个框架 API」,而是几条可以迁移到其他 Agent 项目的工程原则:
#mermaid-svg-dNJjwgQF2TXZxXi3{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-dNJjwgQF2TXZxXi3 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-dNJjwgQF2TXZxXi3 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-dNJjwgQF2TXZxXi3 .error-icon{fill:#552222;}#mermaid-svg-dNJjwgQF2TXZxXi3 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-dNJjwgQF2TXZxXi3 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-dNJjwgQF2TXZxXi3 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-dNJjwgQF2TXZxXi3 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-dNJjwgQF2TXZxXi3 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-dNJjwgQF2TXZxXi3 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-dNJjwgQF2TXZxXi3 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-dNJjwgQF2TXZxXi3 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-dNJjwgQF2TXZxXi3 .marker.cross{stroke:#333333;}#mermaid-svg-dNJjwgQF2TXZxXi3 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-dNJjwgQF2TXZxXi3 p{margin:0;}#mermaid-svg-dNJjwgQF2TXZxXi3 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-dNJjwgQF2TXZxXi3 .cluster-label text{fill:#333;}#mermaid-svg-dNJjwgQF2TXZxXi3 .cluster-label span{color:#333;}#mermaid-svg-dNJjwgQF2TXZxXi3 .cluster-label span p{background-color:transparent;}#mermaid-svg-dNJjwgQF2TXZxXi3 .label text,#mermaid-svg-dNJjwgQF2TXZxXi3 span{fill:#333;color:#333;}#mermaid-svg-dNJjwgQF2TXZxXi3 .node rect,#mermaid-svg-dNJjwgQF2TXZxXi3 .node circle,#mermaid-svg-dNJjwgQF2TXZxXi3 .node ellipse,#mermaid-svg-dNJjwgQF2TXZxXi3 .node polygon,#mermaid-svg-dNJjwgQF2TXZxXi3 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-dNJjwgQF2TXZxXi3 .rough-node .label text,#mermaid-svg-dNJjwgQF2TXZxXi3 .node .label text,#mermaid-svg-dNJjwgQF2TXZxXi3 .image-shape .label,#mermaid-svg-dNJjwgQF2TXZxXi3 .icon-shape .label{text-anchor:middle;}#mermaid-svg-dNJjwgQF2TXZxXi3 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-dNJjwgQF2TXZxXi3 .rough-node .label,#mermaid-svg-dNJjwgQF2TXZxXi3 .node .label,#mermaid-svg-dNJjwgQF2TXZxXi3 .image-shape .label,#mermaid-svg-dNJjwgQF2TXZxXi3 .icon-shape .label{text-align:center;}#mermaid-svg-dNJjwgQF2TXZxXi3 .node.clickable{cursor:pointer;}#mermaid-svg-dNJjwgQF2TXZxXi3 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-dNJjwgQF2TXZxXi3 .arrowheadPath{fill:#333333;}#mermaid-svg-dNJjwgQF2TXZxXi3 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-dNJjwgQF2TXZxXi3 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-dNJjwgQF2TXZxXi3 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-dNJjwgQF2TXZxXi3 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-dNJjwgQF2TXZxXi3 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-dNJjwgQF2TXZxXi3 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-dNJjwgQF2TXZxXi3 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-dNJjwgQF2TXZxXi3 .cluster text{fill:#333;}#mermaid-svg-dNJjwgQF2TXZxXi3 .cluster span{color:#333;}#mermaid-svg-dNJjwgQF2TXZxXi3 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-dNJjwgQF2TXZxXi3 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-dNJjwgQF2TXZxXi3 rect.text{fill:none;stroke-width:0;}#mermaid-svg-dNJjwgQF2TXZxXi3 .icon-shape,#mermaid-svg-dNJjwgQF2TXZxXi3 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-dNJjwgQF2TXZxXi3 .icon-shape p,#mermaid-svg-dNJjwgQF2TXZxXi3 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-dNJjwgQF2TXZxXi3 .icon-shape .label rect,#mermaid-svg-dNJjwgQF2TXZxXi3 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-dNJjwgQF2TXZxXi3 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-dNJjwgQF2TXZxXi3 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-dNJjwgQF2TXZxXi3 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} ① 显式状态优于隐式上下文
所有跨节点信息进状态契约
受约束的
可信 Agent
② 代码控制路由,模型在框内发挥
高风险决策不让模型自由发挥
③ 关键数值要有确定性来源
代码提取覆盖模型回忆
④ 阈值进代码不进提示词
规则透明、可单测、可审计
⑤ 关键节点强制人工介入
人保留最终决定权
⑥ 任何模型输出都准备兜底
失败要降级,不要崩溃
具体展开:
-
显式状态优于隐式上下文。 把所有需要共享的数据写进带 reducer 的状态契约,并行才安全,流程才可追溯;
-
代码控制路由,模型在框内发挥。 让模型做它擅长的取证和表达,把「去哪、能不能跳过、定几级」交给确定性代码;
-
关键数值必须有确定性来源。 凡是会影响定级的数字,都从真实工具结果提取并覆盖模型字段,杜绝「假健康」;
-
阈值放在代码里,而不是藏在提示词里。 这样规则透明、可单测、可审计,也不会因模型换了一版提示词就漂移;
-
关键节点强制人工介入。 用检查点 + 中断实现可暂停、可恢复的审批,人始终保留最终决定权;
-
任何模型输出都要准备兜底。 结构化输出有多层阶梯,最后还有确定性报告,系统可以降级,但不能崩溃或静默失败;
-
分层与接缝让系统可验证、可演示。 runtime 不依赖 surface 才能多入口复用,fake/real 接缝让作品在离线环境也能完整跑通。
结语
回头看,sec-investigator 真正想展示的不是「我调用了一个大模型」,而是一整套约束模型自治边界的工程方法:用显式状态图承载真实调查流程,用确定性证据和代码策略守住可信底线,用人工审批守住安全底线,再用多层兜底保证任何情况下都有可交付的结果。
对我而言,这个项目把实习阶段那些「只可意会」的排查经验,变成了可以被代码表达、被测试验证、被他人阅读的设计资产。如果你也在做面向真实业务的 Agent,希望这篇文章能提供一个具体的参考:Agent 的价值不在于它有多自由,而在于它在被严格约束的边界内,依然可靠地把事情做对。
仓库地址再放一次,欢迎交流:https://github.com/Yuan1433223/sec-investigator