低调学安全

CyberStrikeAI Agent 优化:解耦的不是「工具」,是「时间」

以前 Agent 调一次 nmap / sqlmap,整轮迭代就得干等到工具结束。
现在工具丢给后台 worker 跑,本轮只等待有限时间;超时返回 execution_id,Agent 可以先继续推理,结果后续再读,需要时再 wait / cancel。

Image

0. 这套机制实际做到了什么

先划清边界。它不是:

  • LLM 在等 nmap 时还能继续「边想边跑」
  • 一轮里自动并行调度任意多个阻塞工具

它实际做到的是:

  1. 工具调用对 Eino/模型仍是同步 tool call(协议兼容)。
  2. 阻塞劳动下沉到 cancellable worker,带显式 execution_id。
  3. Agent 对本轮调用只做 有界 Wait(tool_wait_timeout_seconds)。
  4. 软等待到期后:本轮 tool result 返回(含句柄),下一轮迭代可以继续;worker 可继续跑。
  5. 后续用 wait_tool_execution / get_tool_execution / cancel_tool_execution 观察或终止。

一句话:

解耦发生在「软超时之后的跨轮决策」:软等待内,当前轮停在等工具,下一轮还开不了;软超时返回句柄后,本轮才能结束,后续迭代才能继续,后台任务则接着跑。

这是当前阶段在同步 tool-call 约束下的务实落点,不是终点;后面会围绕调度、超时语义和结果回灌继续打磨。


一、问题本质:串行不只是慢,还会把控制平面绑死

经典 tool-calling 循环:

思考 → 调工具 → 等结果 → 再思考 → …

对短 API 助手够用;对红队 Agent,工具耗时分布是重尾的:

类型
典型耗时
旧模型下的后果
Fact / 短探测
毫秒~秒
可忽略
中等扫描
分钟
整轮假死
sqlmap / 长脚本 / 外部 MCP
十几分钟~小时
会话焊死、取消语义混乱
远端 MCP 半开/挂死
不确定
runner 被拖进黑洞

此时把 max_iterations 调到 12000 帮助有限:中间一次数十分钟阻塞,后面本可发生的决策全部排队。

麻烦往往不只是「慢」,而是四类连锁问题:

  1. 决策窗口被占死:扫描未完成前,模型无法换路、写 Fact、开短工具。
  2. 取消语义绑死:停「等待」还是停「执行」分不清。
  3. 上下文与存储双爆:巨大 stdout 进 messages + DB,续跑再灌一次。
  4. 多会话互相伤害:一个坏 MCP server 拖垮全局。

所以正确命题不是「超时调大」,而是:

工具的物理耗时,不该定义 Agent 控制平面的阻塞时间;但协议层仍必须给出可推理的 tool result。


二、三条常见捷径为什么不行

1. Fire-and-forget

返回「已提交」就结束。渗透场景里「没结果」极易被模型幻觉成「没洞」。因果链断了,比慢更危险。

2. 事件回调推进下一轮

与 Eino ADK / OpenAI-compatible tool-calling 假设冲突:通常要 本轮 tool result 回来,才进入下一轮 model call。硬改事件驱动等于重写 runtime。

3. 无限延长超时

只是把假死合法化。runner 仍被占着,用户无法插话改策略。

可行解必须同时满足

  1. 对模型:同步 tool call
  2. 对 runner:有界等待
  3. 对执行:可延续生命周期(超时 ≠ 默认杀进程)
  4. 对系统:可取消、限流、熔断、输出上限、恢复一致

CyberStrikeAI 的答案是:ExecutionService + 控制面工具 + 结果治理 + 取消矩阵。


三、架构主轴:同步门面 × 异步执行

核心注释(internal/mcp/execution_service.go):

ExecutionService keeps Eino-facing tool calls synchronous while moving the untrusted blocking work into cancellable workers with explicit execution IDs.

3.1 主路径(当前生产路径)

工具链路是:

Eino ToolsNode
  → einomcp.mcpBridgeTool.InvokableRun
  → Agent.ExecuteMCPToolForConversation
  → mcp.Server.CallTool / ExternalMCPManager.CallTool
  → ExecutionService.Submit + Wait(soft)

einomcp 还要处理一个协议缝:Eino 工具通道主要返回字符串,于是用

__CYBERSTRIKE_AI_TOOL_ERROR__\n + body

把 MCP 的 IsError 传到上层,再在 eino_adk_run_loop 里还原为 success/isError。软超时结果会带这个前缀——模型侧仍看到“错误通道”,但 UI 会另行识别。

3.2 CallTool 的两段式

以内部 MCP 为例(外部 MCP 同构,多一个 PreRun 抢槽):

Submit(run=真实 handler)
  → 落库 queued,启动 worker
Wait(ctx, id, tool_wait_timeout)
  → 完成:返回真实 ToolResult
  → ErrExecutionWaitTimeout:返回「后台仍在跑」文案 + execution_id

Worker 侧:

runCtx := context.WithoutCancel(ctx) // 关键:剥离父 ctx 的 cancel/deadline

这意味着:Wait 结束、甚至 Wait 的 ctx 取消,都不自动等于 worker 被杀。
生命周期分离是刻意的;否则「软超时后继续后台跑」不成立。

3.3 状态机:库状态 vs 展示状态

ExecutionService / DB 真实状态:

状态
含义
queued
已创建,可能在等 worker 或并发槽
running
worker 执行中
completed
 / failed / cancelled / hard_timeout / orphaned
终态

前端展示态 background_running:
不是库字段,而是 eino_adk_run_loop 在推 tool_result 时,若判定为「MCP 后台等待结果」,写入 SSE:

  • status=background_running
  • UI isError=false(不标红)
  • 同时保留 modelFacingIsError=true(模型仍走错误通道提示)

判定函数 isMCPBackgroundWaitResult 要求同时具备:

  • 含 execution_id
  • status 为 running/queued
  • 含软等待文案信号(「工具已提交到后台执行」/ wait_timeout / still running 等)

这是典型的 双通道语义:给人看的状态,和给模型的激励信号,故意不完全相同。


四、双时钟:把「等待」和「处决」拆开

很多人以为只有一个 timeout。实现里至少有多层时间边界,职责不同。

4.1 软等待:tool_wait_timeout_seconds

  • 定义:本次 Agent-facing tool call 最多阻塞多久。
  • 到期:返回句柄,worker 继续。
  • 默认/示例:代码默认常见 60s;config.example.yaml 可到 300s;文档建议长任务场景 30–60s 起步,不建议靠把软等待调成数分钟来“解决”长任务。
  • 0:等到完成(退回旧世界)。

4.2 Agent 包装的 tool_timeout_minutes

Agent 在调用 MCP 前可能:

toolCtx, cancel = context.WithTimeout(ctx, ToolTimeoutMinutes)

它主要约束的是 CallTool 里的 Wait 所使用的 ctx。
但 Submit 后 worker 用 WithoutCancel(toolCtx),deadline 被剥离。

因此:

场景
Wait 侧
Worker 侧
软等待先到期
返回后台句柄
继续跑
软等待=0 且硬 deadline 先到
Wait 返回 DeadlineExceeded
仍可能继续跑
(除非另有 cancel)
显式 cancel / 会话收尾
—
cancel

ExecutionRequest.HardTimeout 字段在 ExecutionService 里存在,且可映射到 hard_timeout 状态;但当前 Server.CallTool / ExternalMCPManager.CallTool并未传入 HardTimeout。
也就是说:“硬超时杀后台”不是靠这个字段自动完成的,而是靠会话取消、cancel_tool_execution、exec 空闲超时、工具内部逻辑等。

配置说明里值得写清楚:不要以为配了 tool_timeout_minutes,后台 sqlmap 就一定在 60 分钟被杀。

4.3 其他边界

  • shell_no_output_timeout_seconds:防 exec 静默挂死
  • 外部 MCP 并发槽 + 熔断:防坏依赖放大
  • orphaned reconcile:进程没了,DB 不能永远 running

五、控制面工具:把生命周期操作变成可推理原语

仅返回 execution_id 不够;模型需要一等工具:

工具
语义
get_tool_execution
只读快照
wait_tool_execution
再有界等待(默认 60s,最大 600s)
cancel_tool_execution
取消并可选 reason

5.1 防套娃:控制工具的外层软等待为 0

isExecutionControlTool 为真时,CallTool 把外层 waitTimeout 置 0。
否则会出现:wait_tool_execution 自己又被 30s 外层软超时打断——语义崩坏。

5.2 「wait 超时」≠「目标失败」≠「本次 wait 失败」

单测钉死了一件事:当 wait_tool_execution 返回「目标仍 running + 本次等待到达」时:

  • 该次 wait execution 在服务侧记为 **completed**
  • 模型正文仍可保留 IsError(提示“别当最终结果”)
  • 前端不应标红成工具失败

这是训练信号治理:如果把「还没跑完」记成失败,模型会学到错误策略(乱 cancel / 重复开扫)。

5.3 模型仍可能把自己堵回去

系统给的是能力,不是调度器。若模型一拿到 execution_id 就:

wait_tool_execution(timeout_seconds=600)

迭代时钟会再次被占满。
更稳妥的用法是:短 wait + 穿插其他工作 + 必要时 cancel,而不是把软超时省下来的时间,立刻用长 wait 还回去。


六、取消矩阵:产品语义比 cancel() 更难

实现上取消入口并不止一个,意图也不同。

用户意图
典型路径
是否杀 MCP worker
是否杀 Eino execute
停止任务 / 结束会话
CancelTask(ErrTaskCancelled)
 + ToolCanceler
是(按 conversation)
是(AbortActiveEinoExecute)
中断并继续
CancelTask(ErrInterruptContinue)否
(单测强制)
视当前是否在 execute;有独立 abort 路径
监控页终止单个工具
CancelToolExecutionWithNote
指定 id
—
Agent 调 cancel_tool_execution
同上
指定 id
—

「中断并继续」不批量杀工具,是为了保住半小时扫描,让后续轮次还能 wait_tool_execution。
这不是边角;这是长任务产品能不能用的分水岭。

另外还有平行路径:

  • MCP:ExecutionService + ToolRunRegistry
  • Eino filesystem execute:EinoExecuteRunRegistry(专门为 amass 等长命令的中断说明合并)

文章若只谈 MCP,会漏掉真实系统里的第二条生命线。


七、外部 MCP:把不可信依赖关进隔离区

外部 MCP 的 CallTool 在 PreRun 里抢槽:

  1. per-server semaphore(默认 2)
  2. global semaphore(默认 16)
  3. 连续失败熔断(阈值 + 冷却)

测试验证过关键不变量:

  • 软超时后,调用方带着 execution_id 返回;
  • 即便第二调用还在 queued 抢槽,也不会把 Agent Wait 无限堵死;
  • 取消 Wait 的 ctx 后,worker 仍可跑完并进入 completed。

这解决的是第二类串行:排队时间也不该绑架迭代。


八、结果治理:异步之后,真正会炸的是上下文

路径:

完成 → NormalizeToolResultForStorage → 内存 execution → DB → 返回 Agent

目标是 canonical result:模型、监控、get/wait、续跑尽量同一份兜底后正文。

Eino 侧还有一层展示一致性:

  • ADK tool_result 推送以 reduction 后、进 ChatModel 的正文为准;
  • 避免 ToolInvokeNotify 在 reduction 前抢先推全量输出,造成 UI/监控与 agent 所见不一致;
  • UpdateMCPExecutionDisplayResult 把监控展示同步成模型可见正文。

续跑使用 LastAgentTraceInput(model-facing trace),并在恢复入口再次裁剪历史超大 tool 内容——覆盖升级前脏数据、手工导入、阈值调低等现实情况。

对长程渗透,这决定你能不能「后台扫十分钟,上下文只留句柄与摘要」。


九、和渗透心智的对齐:状态空间搜索需要「可调度的时间」

Skill 体系把渗透看成状态空间搜索:路径从黑板上的已验证事实涌现。

旧串行模型把搜索退化成单线程流水线。
新模型允许更接近指挥节奏的策略:

发起长扫描(软超时拿 execution_id)
  → 写 Fact / enrichment / 换路 / 短工具
  → 短 wait_tool_execution 探进度
  → 完成则吸收;未完成则继续开拓其他 frontier
  → 价值下降则 cancel,释放目标与本地资源

再次强调边界:这是 跨轮重叠,不是完整的 multi-inflight tool futures runtime。
但对 Eino 同步语义,这是务实切面:先拆掉最大痛点(无限阻塞),再谈更激进的并行编排。


十、落地建议(配置 × 产品 × 提示词 × 运维)

10.1 配置怎么配

长任务场景建议从这里起步,再按实测调:

agent:
max_iterations:800# 太大等于放弃循环保护
tool_timeout_minutes:60# 主要约束 Wait/调用包装,不等于自动硬杀一切后台
tool_wait_timeout_seconds:30# 让长任务尽快交还迭代权;别指望靠调大它“完成扫描”
external_mcp_max_concurrent_per_server:2
external_mcp_max_concurrent_total:16
external_mcp_circuit_failure_threshold:3
external_mcp_circuit_cooldown_seconds:60
shell_no_output_timeout_seconds:1200

multi_agent:
eino_middleware:
reduction_enable:true
reduction_max_length_for_trunc:50000

经验法则:

旋钮
该调大的信号
不该调大的信号
tool_wait_timeout_seconds
短工具被误判后台、频繁无意义 wait
想让 sqlmap「一轮等完」
tool_timeout_minutes
合法长任务在 Wait=0 路径被过早打断
指望它清理所有后台孤儿
并发上限
外部 MCP 吞吐不够且 server 健康
server 已抖动还继续加压
reduction 上限
关键证据总被截断
想把全量 nmap 塞进上下文

10.2 Agent 策略(提示词 / skill 应强调)

  1. 长工具软超时后:先决策是否值得继续等,不要反射性长 wait。
  2. wait 用短脉冲(如 15–60s),中间穿插其他假设检验。
  3. 明确区分:failed / cancelled / 仍 running。
  4. 用户「中断并继续」后:优先 get/wait 已有 execution_id,不要无脑重开扫描。
  5. 价值下降或目标变更:主动 cancel_tool_execution 并写负结果 Fact。

10.3 产品交互

  1. 后台运行用 background_running 展示,不标红。
  2. 停止任务 vs 中断并继续:取消矩阵必须对用户可感知。
  3. 监控页提供单 execution 终止,并带回 note 合并进工具结果。

10.4 运维与正确性

  1. 启动 orphaned reconcile,避免幽灵 running。
  2. 观察熔断打开频率:过高说明外部 MCP 质量或并发策略有问题。
  3. 抽查 DB 与 agent 正文是否同一份 capped result。
  4. 用固定对话回归:
调用 exec 执行 sleep 120;超过 10 秒未完成则回报 execution_id;
再 wait_tool_execution 5 秒;仍未完成则 cancel,并说明最终状态。

预期:首轮后台句柄、wait 超时不标红失败、cancel 后状态自洽。


十一、深度对照:优化前后时序

旧世界

Agent ──[阻塞 40min 等 nmap]──────────────────────► 下一轮思考
Tool  ████████████████████████████████████████████

新世界(软等待=30s)

Agent ──[Wait≤30s]──► 得 execution_id ──► 短工具/写Fact/再决策 ──► wait/cancel
Tool  ████████████████████████████████████████████(可并行于后续迭代)
                 ▲
           软超时只交还迭代权,不默认杀 Tool

你省下的不是 nmap 本身的 CPU,而是 决策密度 与 尾延迟隔离。


十二、已知边界,也是下一步方向

  1. 软等待内仍阻塞当前 tool call;解放的是后续迭代。
  2. **HardTimeout 字段未接到 CallTool Submit**;勿高估 tool_timeout_minutes 对后台 worker 的处决力。
  3. **background_running 是 UI/SSE 展示态**,不是 DB 枚举值。
  4. 模型侧软超时走 IsError/ToolErrorPrefix;依赖提示词与控制工具描述,弱模型可能误判。
  5. execute 与 MCP 是平行生命周期;取消与中断路径不完全共用一套对象。
  6. 外部 MCP 内部缓冲不可控;本系统只能在入口后兜底与限流。
  7. 超长工具输出会先落盘再截断:全文写入 tmp/reduction/.../trunc/<execution_id>(或 reduction_root_dir),上下文/DB 只保留带路径的 <persisted-output> 预览,可用 read_file 回读。
  8. 没有自动最优调度器;长 wait 仍可把收益吐回去。

这些是当前契约,也是持续探索的入口:例如更完整的硬超时接线、execute/MCP 生命周期统一、结果完成唤醒与回灌、以及更稳的多工具并行编排。平台先把「不可能」变成「可编排」;下一步是让编排更省心、更少依赖模型自觉。


十三、若只记住一张契约图

控制平面(Agent / Eino)
  同步 tool call
  有界 Wait
  跨轮继续推理
        │
        │ execution_id
        ▼
调度平面(ExecutionService)
  queued → running → terminal
  Wait / Cancel / OnDone
        │
        ▼
数据平面(worker / 外部 MCP / execute)
  不可信阻塞劳动
        │
        ▼
治理平面
  输出上限 · 并发 · 熔断 · orphaned · 取消矩阵 · 双通道展示

优化的本质不是多几个异步 API,而是完成角色分离:

  • Agent:决策与调度
  • ExecutionService:生命周期与观察
  • Worker:劳动
  • Governance:唯一真相与故障边界

十四、结语

评测榜上的 Agent 比「题能不能做对」;生产环境里的安全 Agent 比:

  • 长工具会不会把会话焊死
  • 用户能不能中途改口且不误杀扫描
  • 多插件会不会互相拖垮
  • 两小时作战会不会撑爆上下文与数据库
  • 重启后世界是否仍自洽

CyberStrikeAI 这轮优化给出的阶段性答案是:

不要让工具的物理耗时,定义 Agent 的思维节拍。
给执行一个 ID,给等待一个上限,给取消一张矩阵,给结果一份 canonical 真相——然后把下一轮思考的权利,交还给迭代循环。

在同步 tool-call 约束下,这已经是比较稳的务实方案;距离更理想的 Agent 运行时还有空间。我们会继续沿着真实渗透场景打磨:超时与取消语义、结果回灌、多工具编排,以及模型和 skill 更好用的默认策略。欢迎一起验证、拍砖、共建。


代码索引(便于对照本文)

主题
位置
Submit/Wait/Cancel/WithoutCancel
internal/mcp/execution_service.go
内置有界等待
internal/mcp/server.go
 → CallTool
外部 MCP 抢槽/熔断
internal/mcp/external_manager.go
控制面工具
internal/mcp/execution_control_tools.go
Eino 桥与 ToolErrorPrefix
internal/einomcp/mcp_tools.go
background_running
 双通道
internal/multiagent/eino_adk_run_loop.go
中断并继续不杀工具
internal/handler/task_manager_tool_cancel_test.go
execute 平行取消
internal/mcp/run_context.go
、internal/handler/task_manager.go
结果兜底
internal/mcp/tool_result_guard.go
orphaned 收尾
internal/monitor/reconcile.go
治理文档
docs/zh-CN/tool-execution-governance.md