跳到主要内容

越过单 agent:沙箱执行与多 agent 编排

30 秒导读: 前面几章讲的都是「一个 agent 的一次回合」。真实场景会往两个方向长出去: 一是让 agent 跑的命令/代码跑在安全、可替换的地方(沙箱);二是让多个 agent 分工协作 (swarm 自治接力、graph 显式编排)。这一章把这两个子系统讲清楚——它们其实共用了同一套设计 直觉:流式接口 + 可插拔实现 + 可序列化状态

本章是子库的最后一章。前面的地基请按需回看:

  • 01-agent-loop.md:递归事件循环与流式回合(本章的 stream_async 都来自这里)。
  • 02-tools.md:工具系统(沙箱把能力变成工具,swarm 把 handoff 变成工具)。
  • 04-control-plane.md:hooks 与人类介入(多 agent 的 BeforeNodeCallEvent 是它的延伸)。
  • 05-context-and-durability.md:会话与检查点(多 agent 的 serialize_state 复用同一套)。

1. 这一章讲什么:两条「扩展到真实世界」的路

前几章的主角是单个 Agent 的事件循环:接到 prompt,调模型,调工具,再调模型,直到收尾。

这套循环足以做一个聊天助手,但一旦要落到真实、复杂的场景,会撞到两堵墙:

撞到的墙是什么问题本章的子系统
agent 要执行命令/代码,但直接在本机跑很危险执行环境问题沙箱(Sandbox)
一个 agent 扛不动整个任务,需要分工编排问题多 agent(Swarm / Graph)

这两件事看似无关,却共享同一套设计品味,值得放在一章里对照着看:

  • 都用「流式异步生成器」当主接口(async def ... -> AsyncGenerator),把「边跑边出」做成一等公民;
  • 都把抽象基类和具体实现分开,让你换后端(换成 Docker、换成 Graph)而不改调用方;
  • 都能把中间状态序列化,从而支持超时、取消、断点续跑。

下面先讲沙箱(相对独立、边界清晰),再讲多 agent(更大、更热闹)。


2. 沙箱:让「执行」变成可替换、可上锁的一层

2.1 它要解决的小问题

场景很具体:你给 agent 配了一个「跑 shell 命令」的工具,或者一个「执行 Python 代码」的工具。 模型说「跑 rm -rf /tmp/x」,这条命令在哪儿跑?

  • 直接在你的笔记本上跑 → 没有任何隔离,模型一旦被诱导就能删你的文件。
  • 跑在一个 Docker 容器里 → 炸了也只炸容器。
  • 跑在一台远程机器上 → 本地干净,算力在别处。

沙箱这一层就是把「命令/代码/文件操作具体在哪个环境执行」抽象出来,让工具代码只面对一个统一接口, 底层是本机、容器还是远程 SSH,可以随时替换。

2.2 顶层全景:一个抽象 + 一条 shell 捷径 + 三个后端

先看这张图。怎么读: 从上到下是「抽象 → 半成品 → 成品」,越往下越具体;最右边一列是共用的进程引擎。

┌──────────────────────────────┐
抽象层(定接口) │ Sandbox (ABC) │ base.py
│ 6 个抽象操作 + get_tools │
│ + execute/read_text 便利法 │
└───────────────┬──────────────┘
│ 继承
┌──────────────────────────┼───────────────────────────┐
│ │ │
┌────────▼─────────┐ ┌─────────▼──────────┐ │ 直接继承 Sandbox
│ PosixShellSandbox│ │ (未来的 API 后端) │ ┌─────────▼──────────────────┐
│ 用 shell 命令实现 │ │ Jupyter / 云沙箱 │ │ NotASandboxLocalEnvironment │
│ 文件/代码操作(ABC)│ └────────────────────┘ │ 直连本机, 无隔离(默认兜底) │
└───┬──────────┬───┘ └─────────────────────────────┘
│ │ (文件操作走 pathlib/os)
┌─────▼────┐ ┌───▼──────┐
│ Docker │ │ Ssh │ ←── 只需实现 execute_streaming
│ Sandbox │ │ Sandbox │ (怎么把一条命令送进目标环境)
└────┬─────┘ └────┬─────┘
└─────┬──────┘
┌────▼─────────────┐
│ _stream_process │ stream_process.py
│ 进程监督引擎: │ spawn / 流式读 / 超时 / 杀进程树
│ 所有 shell 后端共用│
└──────────────────┘

各部件一句话职责:

部件干什么在哪
Sandbox抽象基类,定义 6 个抽象操作 + get_tools + 便利方法strands-py/src/strands/sandbox/base.py:36
PosixShellSandbox半成品:文件/代码操作用 shell 命令实现,子类只补 execute_streamingstrands-py/src/strands/sandbox/posix_shell.py:77
DockerSandbox成品:命令送进 docker execstrands-py/src/strands/sandbox/docker.py:19
SshSandbox成品:命令送进 sshstrands-py/src/strands/sandbox/ssh.py:67
NotASandboxLocalEnvironment兜底:直连本机、无隔离,名字就是警告strands-py/src/strands/sandbox/not_a_sandbox_local_environment.py:31
_stream_process进程监督引擎:spawn、流式读、超时、杀进程树strands-py/src/strands/sandbox/stream_process.py:42

一句话直觉: Sandbox 是「插座标准」,PosixShellSandbox 是「只要有 shell 就能通电的转接头」, Docker/SSH 是插上去的电器,_stream_process 是背后的电路。

2.3 六个抽象操作:一个沙箱最少要会什么

Sandbox 逼子类实现 6 件事,分三组(base.py:69-201):

抽象方法干什么
命令执行execute_streaming跑一条 shell 命令,边跑边出
代码执行execute_code_streaming把源码喂给解释器(python3/node)跑
文件 I/Oread_file / write_file / remove_file / list_files读/写/删/列文件

再加一个非抽象get_tools()(base.py:205):把沙箱能力暴露成 agent 能调的工具—— 基类默认返回空列表,具体沙箱重写它(下文 2.9)。

2.4 巧妙点一:流式是原语,非流式是「喝干这条流」

一个容易忽略但很关键的设计:只有流式方法是抽象的,非流式方法是基类白送的

execute(base.py:219)干了什么——它根本不是抽象方法,而是消费 execute_streaming 这条流, 把最后那个 ExecutionResult 捞出来返回:

# 示意,非源码 —— 对应 base.py:249
async def execute(self, command, ...) -> ExecutionResult:
async for chunk in self.execute_streaming(command, ...):
if isinstance(chunk, ExecutionResult): # 流的最后一颗是最终结果
return chunk
raise RuntimeError("execute_streaming() 没吐出 ExecutionResult")

这条流里流的是两种东西(用 isinstance 区分,而不是 TS 那种 type: 'streamChunk' 判别字段):

  • StreamChunk:一段 stdout 或 stderr 文本(types.py:17,带 stream_type 分辨来源);
  • ExecutionResult:最后一颗,带 exit_code / 完整 stdout / stderr / output_files(types.py:72)。

重点看: 子类只要实现「流式」这一个原语,execute / execute_code / read_text / write_text 这些便利方法全都免费得到。这就是「抽象少、白送多」的接口设计。

2.5 巧妙点二:PosixShellSandbox —— 只要有 shell,文件操作全能凑出来

想做一个新沙箱(比如连到某云运行时),你得实现 6 个操作吗?不用。 PosixShellSandbox(posix_shell.py:77)的思路是:只要目标环境有个 POSIX shell,读写文件、跑代码 都能用 shell 命令拼出来。于是子类只需实现 execute_streaming 一个方法,其余五个全继承。

它怎么把「读文件」变成「跑命令」?靠 base64 + heredoc:

操作拼出来的 shell 命令(简化)为什么这么做
read_filebase64 < <路径> 再本地解码base64 输出是纯 ASCII,能安全穿过 stdout;二进制文件也不怕
write_filemkdir -p ... && base64 -d << 'EOF' > 路径内容 base64 后塞进带引号的 heredoc,任意二进制/换行/引号都不会注入
execute_codebase64 -d << 'EOF' | python3源码 base64 后解码再管道给解释器,躲开 shell 元字符
list_filestest -d 路径 || exit 77; ls -1ap 路径用退出码 77 把「目录不存在」和「ls 自己失败」区分开

真源码看 execute_code_streaming(posix_shell.py:105),核心就三行:

# 示意,非源码 —— 对应 posix_shell.py:138
if not LANGUAGE_PATTERN.fullmatch(language): # 先卡语言名,防注入
raise ValueError(...)
encoded = base64.b64encode(code.encode()).decode() # 源码编码成 ASCII
command = f"base64 -d << '{eof}' | {language}\n{encoded}\n{eof}" # 解码后管道给解释器

这里有个微妙但重要的坑,专门在 build_shell_env_prefix(posix_shell.py:41)的注释里点破: 环境变量必须用 export KEY=VALUE && ...不能env KEY=VALUE cmd 包一层。因为 execute_codebase64 ... | python3 这样的管道,env 只会绑住管道左边那半截,变量根本到不了 右边的解释器;export 是设进 shell 自身,整条管道都继承。

2.6 三个后端:各自只补「怎么送命令进去」

有了 PosixShellSandbox 这个半成品,三个具体后端都很薄:

DockerSandbox(docker.py:44):把命令包成 docker exec [--user ...] [-w ...] [-e K=V ...] -- 容器 sh -c "命令"。 注意那个 --(docker.py:92)——它终止 flag 解析,保证容器名永远被当成位置参数;否则一个叫 --privileged 的容器名会被 Docker 当成开关,悄悄改掉安全选项。环境变量走 Docker 原生的 -e 标志, 不用 shell 转义(值是当 argv 传的,Docker 原样存);这跟 SSH 后端要拼 shell 字符串、必须转义正好相反。

SshSandbox(ssh.py:126):把命令包成 ssh -o StrictHostKeyChecking=... -o BatchMode=yes -p 端口 -- host "cd 目录 && export ... && 命令"。 两个安全设计值得记:

  • BatchMode=yes 关掉一切交互式提示,强制走密钥认证(不会卡在等密码);
  • SSH 选项白名单 _ALLOWED_SSH_OPTIONS(ssh.py:23):像 ProxyCommandLocalCommand 这种能 执行本地命令的选项被排除在外,构造时就拦下,除非你显式 allow_unsafe_ssh_options=True。同样有 -- 终止符(ssh.py:175),防止 -oProxyCommand=evil 这样的假 host 变成本机任意命令执行。

NotASandboxLocalEnvironment(not_a_sandbox_local_environment.py:31):兜底方案,直连本机、零隔离。 名字取得这么难听(TS 那边也叫 not-a-sandbox)就是当警告牌用。它有个区别于 shell 后端的优化:文件操作走 原生 pathlib/os(read_file 直接 read_bytes,list_filesos.scandir 还能报真实 size), 不绕 shell,更快也更准。命令/代码执行才 spawn 一个本机 sh

2.7 巧妙点三:_stream_process —— 一个把进程「养好又杀干净」的引擎

Docker 和 SSH 后端最后都汇到同一个函数 _stream_process(stream_process.py:42):给它一个 argv, 它负责 spawn 进程、把 stdout/stderr 流式吐成 StreamChunk、最后吐一个 ExecutionResult。三个精华:

① 进程组,防「僵尸管道」。 子进程用 start_new_session 开在自己的进程组里(stream_process.py:30_USE_PROCESS_GROUP),这样能一次杀掉整棵进程树。注释点破了原因:sh -c 'cmd; sleep 60' 里,父进程死了 sleep 还攥着管道写端不放,读端永远等不到 EOF,生成器就挂死。用 killpg 杀整个组才干净。

② 超时是墙钟,从 spawn 开始算。 enforce_timeout(stream_process.py:105)是个独立协程,睡够 timeout 秒后若进程还活着,就杀进程树并往队列塞终止信号;消费端看到 timed_out 就抛 SandboxTimeoutError。 关键:超时不会因为持续有输出而重置(不是「空闲超时」是「总时长超时」)。

③ 取消是协作式的。 整个清理逻辑放在 finally(stream_process.py:127):消费方一旦取消任务或关掉生成器, finally 就杀进程、取消 pump 协程、gather(..., return_exceptions=True) 收尾。信号终止会被映射成 128 + 信号号(stream_process.py:125,SIGKILL → 137),和 shell 惯例一致。

2.8 安全细节速查

沙箱这层的「纵深防御」散落在几处,汇总一下:

防的是什么手段位置
解释器名注入LANGUAGE_PATTERN = ^[a-zA-Z0-9._-]+$,用 fullmatchconstants.py:19
环境变量名注入ENV_KEY_PATTERN(合法 POSIX 名),非法直接报错constants.py:25
任意源码/二进制注入base64 + 带引号 heredocposix_shell.py:105,171
假容器名/假 host 当 flagargv 里加 -- 终止符docker.py:92ssh.py:175
危险 SSH 选项选项白名单,构造时校验ssh.py:23,118

为什么 fullmatch 不是 match? constants.py 的文档字符串专门解释:Python 里 $ 也匹配结尾换行前, re.match 会放过 "python3\n",让换行后的第二条语句偷渡进去;fullmatch 才复刻 JS /^...$/.test() 的「锚到真正的字符串结尾」。这是个真实踩过的坑。

2.9 沙箱怎么接进 Agent:能力即工具

沙箱不是凭空存在的,它通过 get_tools() 把自己变成 agent 的工具。串起来看:

  • Agent.__init__ 里,没传 sandbox 就兜底成 NotASandboxLocalEnvironment()(agent.py:292);
  • 初始化工具时,遍历 self._sandbox.get_tools() 注册进 registry——但如果用户已经注册过同名工具就跳过 (agent.py:368-377),让用户能覆盖;
  • Docker/SSH 的 get_tools()(docker.py:99ssh.py:182)返回两个绑定到本沙箱的工具: sandbox_file_editorsandbox_bash,描述里还带上「文件在容器 X / 主机 Y」的提示;
  • 工具运行时通过 context.agent.sandbox(agent.py:614 的属性)拿回沙箱,把操作路由过去。
用户 new Agent(sandbox=DockerSandbox("mybox"))

▼ __init__: 遍历 sandbox.get_tools()
注册 sandbox_bash / sandbox_file_editor(同名则跳过)

▼ 模型说「跑 ls」→ 调 sandbox_bash 工具
工具体内 context.agent.sandbox.execute("ls")

▼ DockerSandbox.execute_streaming → docker exec ... → _stream_process

这样,同一个 agent 换个 sandbox 参数,它的 bash/文件工具就整体搬到容器或远程去执行,工具代码一行不改。


3. 多 agent 编排:从「一个 agent」到「一队 agent」

单 agent 顶不住时,Strands 给了三种「把多个 agent 拼起来」的方式,复杂度递增:

方式直觉谁决定下一步典型场景
Agent as tool把子 agent 包成父 agent 的一个工具父 agent 的模型主管 agent 偶尔叫个专家
Swarm一队 agent 围坐,谁需要谁接力agent 自己(调 handoff 工具)开放式协作、动态分工
Graph画一张 DAG,按边和条件跑画的图结构确定的流水线、审批链

它们都建在同一个基座上,先看基座。

3.1 共同基座:MultiAgentBase 与统一结果模型

MultiAgentBase(base.py:179)是 Swarm 和 Graph 的共同抽象基类。它的接口刻意和单 Agent 长得一样:

  • invoke_async(task, ...)(抽象,base.py:192)——异步跑到底,返回 MultiAgentResult;
  • stream_async(...)(base.py:205)——默认实现就是「调 invoke_async,把结果当单个事件 yield」, 子类重写它做真流式;
  • __call__(base.py:230)——同步门面,内部 run_async
  • serialize_state / deserialize_state(base.py:250)——留给子类做检查点。

为什么接口要和单 Agent 一样? 因为这样一个 Graph 就能当另一个 Graph 的节点(嵌套), Swarm 也能塞进 Graph——它们互相之间可以无缝当「一个可调用的东西」。

结果模型是三层嵌套的数据结构(全在 base.py):

MultiAgentResult (整个编排的结果) base.py:128
├─ status: Status base.py:25 (PENDING/EXECUTING/COMPLETED/FAILED/INTERRUPTED)
├─ results: dict[node_id → NodeResult] base.py:43
│ └─ NodeResult.result 可以是:
│ · AgentResult (单 agent 节点)
│ · MultiAgentResult (嵌套的 swarm/graph 节点) ← 递归!
│ · Exception (失败节点)
└─ accumulated_usage / accumulated_metrics (token 和延迟逐层累加)

NodeResult.get_agent_results()(base.py:60)会递归拍平嵌套结果,把深处所有 AgentResult 抽出来—— 这就是嵌套编排能统一取数的原因。整棵结果树还能 to_dict/from_dict(base.py:73,93)做持久化。

3.2 最轻的一档:Agent as tool

不想引入 swarm/graph,只是想让一个 agent 偶尔叫另一个 agent 干活?用 agent.as_tool() (agent.py:951),它返回一个 _AgentAsTool(_agent_as_tool.py:28)——一个把子 agent 包成工具的适配器。

它长得就是个普通工具:接受一个 input 字符串参数(_agent_as_tool.py:110tool_spec), 内部调 self._agent.stream_async(prompt),把子 agent 的中间事件包成 AgentAsToolStreamEvent 转发, 最终结果作为 ToolResultEvent 吐出(_agent_as_tool.py:134)。

两个精华细节:

  • preserve_context=False(默认)时会重置子 agent。 构造时快照子 agent 的初始 messages/state (_agent_as_tool.py:96),每次调用前恢复,保证每次调用都从同一基线开始——这和后面 GraphNode.reset_executor_state 是同一套模式。
  • threading.Lock 串行化(_agent_as_tool.py:87):_reset_agent_state + stream_async 必须原子, 否则并发调用会踩坏同一个 in-flight 的子 agent;拿不到锁就直接回一个「agent 正忙」的错误结果。

子 agent 的中断(interrupt)也能穿透:子 agent 被 hook 打断时,中断经 ToolInterruptEvent 上抛给父 agent, resume 时再把响应喂回子 agent(_agent_as_tool.py:215,275)。这让人类介入能跨 agent 层级工作。

3.3 Swarm:自治协作 + 接力(handoff)

思路: 一队 agent,没有中央调度。谁在干活,干着干着觉得「这事该老王」,就主动把控制权交给老王。 这个「交」的动作就是 handoff,靠一个注入的工具实现。

关键部件:

部件是什么位置
Swarm编排器本体swarm.py:237
SwarmNode包住一个 Agent,记初始状态以便重置swarm.py:66
SharedContext节点间共享的「便签板」,值必须 JSON 可序列化swarm.py:120
SwarmState当前执行态:current_node、历史、handoff 目标/消息、累计指标swarm.py:169

handoff 怎么工作? Swarm 构造时给每个 agent 注入一个 handoff_to_agent 工具 (_inject_swarm_tools,swarm.py:541;工具本体 _create_handoff_tool,swarm.py:571)。 模型调这个工具,就在 SwarmState 上记下「下一个执行谁 + 交接消息」(_handle_handoff,swarm.py:604)。 注意 handoff 不是立刻跳转——它只设置状态,主循环跑完当前节点后才切换。

主循环(_execute_swarm,swarm.py:733)大致是:

while 状态是 EXECUTING:
should_continue()? ── 否 → 标 FAILED 停 swarm.py:188
├─ 超过 max_handoffs / max_iterations?
├─ 超过 execution_timeout?
└─ 反复横跳检测(最近 N 次里独立 agent 太少)?
触发 BeforeNodeCallEvent(可中断 / 可取消节点)
执行当前节点(带 node_timeout) swarm.py:856
节点跑完 → 看 state.handoff_node:
├─ 有 → 切到目标, 发 MultiAgentHandoffEvent, 继续循环
└─ 无 → 「没人接力就算完成」→ COMPLETED 停

进节点前会拼一段上下文(_build_node_input,swarm.py:630):把「交接消息 + 原始任务 + 之前哪些 agent 干过 + 共享便签板 + 还有哪些同伴可叫」揉成一段文本喂给 agent,并明确告诉它 「你有协作工具,不交接就视为任务完成」。这段 prompt 工程是 swarm 能自组织的关键。

反复横跳检测(should_continue,swarm.py:215)是个实用护栏:两个 agent 互相甩锅 (A→B→A→B…)会烧钱烧不出结果,于是「最近 N 次里独立 agent 数量低于阈值」就判定退化并停下。默认关闭。

3.4 Graph:显式 DAG + 条件边

思路: 和 swarm 相反,Graph 里画好节点和有向边,谁跑完喂给谁是确定的,不靠模型自由发挥。 支持环(反馈回路)、条件边、以及节点本身就是另一个 Graph/Swarm(嵌套)。

用起来什么样(建图用 builder 模式,GraphBuilder,graph.py:301):

# 示意,非源码
builder = GraphBuilder()
builder.add_node(researcher, "research") # 节点 = Agent 或 MultiAgentBase
builder.add_node(writer, "write")
builder.add_edge("research", "write") # research 跑完 → write
builder.add_edge("write", "research", # 带条件的反馈边
condition=lambda state: needs_more_research(state))
graph = builder.build() # build 时校验并自动找入口
graph("写一篇关于 AI agent 的文章")

关键部件:

部件是什么位置
GraphBuilder建图 + 校验(找入口、警告无限环)graph.py:301
Graph编排器本体graph.py:494
GraphNodeAgentBaseMultiAgentBase,记初始状态graph.py:223
GraphEdge一条边,带可选 conditiongraph.py:186
GraphState执行态:completed/failed/interrupted 节点集、执行顺序、结果graph.py:107
EdgeConditionWithContext条件边协议(能拿到 invocation_state)graph.py:66

执行模型:批次并行 + 依赖驱动。 主循环(_execute_graph,graph.py:757)从入口节点开始, 一整批 ready 节点并行跑(_execute_nodes_parallel,graph.py:817,用一个共享 asyncio.Queue 实时合流各节点事件),这批跑完再算「哪些节点因此变 ready」(_find_newly_ready_nodes,graph.py:940), 如此推进直到没有 ready 节点。

入口节点们 = 初始 ready 批

▼ 并行跑整批(共享队列合流事件, 任一异常 → fail-fast 取消其余)
批次跑完

▼ _find_newly_ready_nodes: 只看这批的出边指向哪些节点
对每个候选:至少一条入边的条件满足 → 变 ready(OR-join)

▼ 有新 ready → 发 MultiAgentHandoffEvent, 加入下一批;否则结束

条件边的两种签名,靠参数名分派。 这是个精巧设计(EdgeConditionWithContext,graph.py:66): 条件函数可以是老式 Callable[[GraphState], bool],也可以是新式「多接一个 invocation_state」。 _is_context_condition(graph.py:89)用 inspect.signature 检查参数名里有没有 invocation_state 来决定怎么调,并把结果缓存(GraphEdge:193_is_context_condition_cached)。 用 Protocol + **kwargs 而非裸 Callable,是为了以后能加参数而不破坏已有实现。

喂给节点的输入(_build_node_input,graph.py:1148):把「原始任务 + 各上游依赖节点的输出」 拼成一段结构化文本(From <节点>: - Agent: <输出>),只收条件满足的依赖的输出。

live 与 resume 的 join 语义不同(_is_node_ready_for_resume,graph.py:1325 的文档点破): 实时执行用 OR-join(任一满足的入边就触发,抢跑);而从检查点恢复时用 AND-join(等齐所有可通行入边), 因为 resume 时已知全部已完成的工作,没必要靠部分结果抢跑。这个不对称是故意的

3.5 多 agent 专属 hooks(略提)

Swarm 和 Graph 都复用了 04 章的 hook 系统,只是多了一组多 agent 生命周期事件 (hooks/events.py:320 起):

事件何时触发能干什么
MultiAgentInitializedEvent编排器构造完初始化插件
BeforeMultiAgentInvocationEvent / After...整个编排开始/结束全局埋点
BeforeNodeCallEvent每个节点执行前可中断(人类介入)、可取消节点(cancel_node,events.py:350)
AfterNodeCallEvent每个节点执行后反向顺序回调(should_reverse_callbacks)

BeforeNodeCallEvent(events.py:335)最有料:它混入 _Interruptible,既能在节点前抛人类介入中断, 也能设 cancel_node 消息把这个节点直接判失败。Swarm 和 Graph 的主循环里都能看到对它的处理 (swarm.py:767graph.py:990)。注意 strands.experimental.hooks.multiagent 已废弃 (experimental/hooks/multiagent/events.py:16),现在从 strands.hooks 导入。


4. 边界与局限(诚实)

沙箱:

  • Sandbox 抽象不保证隔离——隔离强度取决于后端。默认的 NotASandboxLocalEnvironment 完全没有隔离(not_a_sandbox_local_environment.py:38.. warning::),它是兜底不是安全边界。
  • PosixShellSandboxenv/timeout/cwd 必须由子类兑现(posix_shell.py:91-102 的文档明说): 子类若不把 timeout 接进进程监督,就会静默地无限跑
  • _stream_process 的进程组杀树依赖 os.killpg(stream_process.py:30),非 POSIX 平台退化成只杀单进程, 可能留下孤儿子进程。
  • list_files 走 shell 时 size 永远是 None(posix_shell.py:216);只有原生后端能报大小。

多 agent:

  • Swarm 不支持会话持久化(swarm.py:538:带 session_manager 的 agent 会被拒);节点也不能是 重复实例(swarm.py:531)。
  • Graph 的失败是 fail-fast:任一节点抛异常,整图停(graph.py:1104raise),没有「跳过失败节点继续」。
  • Graph 无执行上限会警告可能死循环(graph.py:490):有环又不设 max_node_executions/execution_timeout 就可能永远跑。
  • Graph 的并行批次用 0.1s 轮询规避竞态(graph.py:842),这是权衡而非零延迟。
  • Python 3.10 下 swarm 的超时只在事件之间检查(swarm.py:476),卡在 await 里的生成器不会被及时打断。

5. 横向对比

沙箱抽象 vs 编排抽象——同一套品味的两次应用:

维度SandboxMultiAgentBase
主接口execute_streaming(流式原语)stream_async(流式)
抽象基类 + 具体实现Sandbox → Docker/SSH/LocalMultiAgentBase → Swarm/Graph
便利门面execute/__call__ 喝干流invoke_async/__call__ 喝干流
可持久化read_file/write_file(数据)serialize_state(编排状态)
谁决定行为换 sandbox 实例换 Swarm/Graph 或改图

Swarm vs Graph——两种编排哲学:

维度SwarmGraph
谁决定下一步agent 自己(handoff 工具)图结构 + 条件边(你定)
结构隐式、动态显式 DAG(可含环)
协调载体注入的 handoff_to_agent 工具 + SharedContext边、依赖、_build_node_input
并行否(一次一个当前节点)是(整批 ready 节点并行)
护栏反复横跳检测、max_handoffsmax_node_executions、node_timeout、fail-fast
适合开放式、难预先编排的任务确定流程、审批链、流水线

同 shelf 的兄弟项目也有各自取舍:多数框架把「代码执行沙箱」和「多 agent 编排」当两个独立库, Strands 的特点是让它们共用同一套流式 + 可插拔 + 可序列化的骨架,并让 Graph 节点能嵌套 Graph/Swarm。


6. 代码地图(本章导航 + 整库汇总)

6.1 本章代码地图

主题文件路径关键符号
沙箱抽象基类 + 6 操作 + 便利法strands-py/src/strands/sandbox/base.pySandboxexecute_streamingexecute_code_streamingread_filewrite_fileremove_filelist_filesget_toolsexecute
沙箱数据类型strands-py/src/strands/sandbox/types.pyStreamChunkExecutionResultFileInfoOutputFile
沙箱错误strands-py/src/strands/sandbox/errors.pySandboxTimeoutErrorSandboxPathNotFoundError
注入校验模式strands-py/src/strands/sandbox/constants.pyLANGUAGE_PATTERNENV_KEY_PATTERN
shell 半成品沙箱strands-py/src/strands/sandbox/posix_shell.pyPosixShellSandboxbuild_shell_env_prefixvalidate_env_keys
Docker 后端strands-py/src/strands/sandbox/docker.pyDockerSandboxexecute_streamingget_tools
SSH 后端strands-py/src/strands/sandbox/ssh.pySshSandbox_ALLOWED_SSH_OPTIONS
本机兜底(无隔离)strands-py/src/strands/sandbox/not_a_sandbox_local_environment.pyNotASandboxLocalEnvironment
进程监督引擎strands-py/src/strands/sandbox/stream_process.py_stream_process_kill_tree_USE_PROCESS_GROUP
沙箱接进 Agentstrands-py/src/strands/agent/agent.pyAgent.__init__(:368 注册)、Agent.sandbox(:614)、as_tool(:951)
多 agent 基座strands-py/src/strands/multiagent/base.pyMultiAgentBaseMultiAgentResultNodeResultStatus
Agent 当工具strands-py/src/strands/agent/_agent_as_tool.py_AgentAsToolstream_reset_agent_state
Swarm 自治协作strands-py/src/strands/multiagent/swarm.pySwarmSwarmNodeSharedContextSwarmState_create_handoff_tool_handle_handoff_execute_swarm
Graph 显式编排strands-py/src/strands/multiagent/graph.pyGraphBuilderGraphGraphNodeGraphEdgeGraphStateEdgeConditionWithContext_is_context_condition_execute_nodes_parallel_find_newly_ready_nodes
多 agent hooksstrands-py/src/strands/hooks/events.pyBeforeNodeCallEventAfterNodeCallEventBeforeMultiAgentInvocationEventAfterMultiAgentInvocationEventMultiAgentInitializedEvent

6.2 整库代码地图汇总(六章合看)

子系统本库章节核心目录入口符号
主事件循环01strands-py/src/strands/event_loop/agent/agent.pyAgentstream_async、事件循环
工具系统02strands-py/src/strands/tools/@toolToolRegistry、MCP client
模型抽象层03strands-py/src/strands/models/Model、各 provider(anthropic.py 等)
控制面04strands-py/src/strands/hooks/HookRegistryBefore*Event/After*Event
上下文与持久化05strands-py/src/strands/session/agent/conversation_manager/SessionManagerConversationManager
沙箱与多 agent06(本章)strands-py/src/strands/sandbox/multiagent/SandboxMultiAgentBaseSwarmGraph

收束一句: 从 01 章的「一个 agent 一次回合」,到本章的「一队 agent 在受控环境里协作」, Strands 始终用同一套语言说话——流式异步生成器 + 抽象基类可插拔 + 状态可序列化。 学懂这条主线,整库就串成了一条线,而不是一堆孤立模块。