Spring AI Alibaba Agent Runtime:五个真实工程问题与取舍

作者:

🇬🇧 English

给个人用的 AI Agent 系统,最先挡路的往往不是"选哪家模型",而是"代码怎么安全执行、长任务怎么不丢、上下文怎么不爆"。spring-harness 用一次一容器的 Docker 沙箱、SQLite 落盘可续跑的长时任务、按完整消息开销估算的上下文压缩、带魔数校验的 RAG,以及手动控制工具执行的 ReAct 与按依赖分层的 PSE 协作,把这几道坎逐一填平。所以我把 Spring AI Alibaba 当成 Agent 能力层,而把 spring-harness 往 Runtime / Harness 的方向继续往下做——LLM → Agent → Harness → Runtime → Workbench,这是一条一步一步往上走的路线。

出发点

spring-harness 是基于 Spring AI Alibaba 的全栈 Agent 框架——Chat / RAG / ReAct Agent / PSE 协作 / 长时任务五种交互模式,全部跑在阿里云百炼(DashScope)或 OpenAI 兼容接口上,境内直连、无需代理。这篇文章不重复 README 的功能清单,只讲五个我认为最真实、也最能反映工程取舍的问题:Docker 沙箱、长时任务、上下文压缩、RAG、ReAct / PSE 协作——它们解决的问题,恰恰是那些"演示时不会暴露、一上真实任务就翻车"的坑。

Docker 沙箱:一次一容器,用完即毁

让 Agent 执行用户代码,安全是第一位的。spring-harness 没有复用宿主机进程,也没有"池化容器复用以省启动开销"——每次执行都起一个全新容器,跑完 --rm 自动销毁:

cmd.add("docker"); cmd.add("run");
cmd.add("--rm");                             // 执行完自动删除容器
cmd.add("--cap-drop"); cmd.add("ALL");       // 丢弃所有 Linux capabilities
cmd.add("--security-opt"); cmd.add("no-new-privileges"); // 禁止 setuid 提权
cmd.add("--network"); cmd.add("none");       // 彻底禁用网络
cmd.add("--read-only");                      // 根文件系统只读
cmd.add("--memory"); cmd.add(memoryMb + "m");       // 内存限制(默认 512MB)
cmd.add("--cpus"); cmd.add(String.valueOf(cpus));   // CPU 限制(默认 1 核)
cmd.add("--pids-limit"); cmd.add("100");           // 进程数上限
cmd.add("--ulimit"); cmd.add("nofile=64:64");      // 文件描述符上限

这段来自 DockerSandboxExecutor.buildDockerCommand。真正有意思的是它对"既要隔离、又要跑编译型语言"的处理:根文件系统只读,但单独给 /tmp 挂一块可写的 tmpfs,并允许 exec,这样 Java/Go/Rust/C/C++ 才能把编译产物写出来再执行:

--tmpfs /tmp:rw,size=128m,exec

代码文件通过 -v /宿主机路径/code.java:/tmp/Main.java:ro 只读挂载进容器(Java 统一叫 Main.java,匹配 public class Main 的源码约定),宿主目录不留代码。

隔离力度之外还有两道闸:

  • 超时强杀:默认 30 秒,waitFor 超时直接 destroyForcibly(),防止死循环代码挂住主线程。
  • 输出限流:stdout/stderr 各限 100KB,读满就打上 ...[输出超过限制,已截断] 截断,防止一次 cat 大文件把几百 KB 挤进模型上下文。

整个沙箱支持 8 种语言(python/node/alpine/temurin/golang/rust/gcc 镜像各就位),并且每次执行都走 docker info 探活——Docker daemon 挂了会拿到一句"请确保 Docker daemon 已启动",而不是一条 IOException 堆栈。

最后主动框一下边界:这套方案定位是个人 Agent 工作台的轻量隔离,而不是面向恶意多租户场景的生产级代码执行沙箱——代码执行默认与宿主环境隔离,针对个人使用场景足够了。懂容器安全的读者会继续追问 rootless、seccomp、AppArmor、Docker daemon 自身的安全边界,这些都不在设计目标内,先说清楚比被追问出来体面。

长时任务:SQLite 落盘 + 可中断 + 可续跑

Chat 场景用户能接受几秒等待,但 ReAct 的 10 轮、PSE 的多 Agent 协作动辄几分钟,必须异步化。spring-harness 的 TaskManager 用固定线程池(TASK_POOL_SIZE=4)跑任务,一套设计解决"并发、中断、持久化、追踪"四个问题。

独立 token 统计。每个任务绑定自己的 TokenUsageTracker(ThreadLocal 任务作用域),主帅 ReAct/PSE 和内部所有 Agent 共用这一个 tracker,并发任务互不污染——否则两个任务同时跑,token 会记混到同一账目上。子线程里还显式重新 bind:

CompletableFuture.runAsync(() -> {
    TokenUsageTracker.bind(currentTracker());
    try { /* 执行 + 记录 */ }
    finally { TokenUsageTracker.unbind(); }
}, executor);

可随时中断cancel() 做两件事:置一个中断信号位 + Future.cancel(true)。ReAct 每轮循环开头、PSE 每个关键阶段前都检查这个信号,协作式中断而不是粗暴刺穿线程:

if (cancelled.getAsBoolean()) {
    callback.accept(new ReActStreamEvent("interrupted", iteration + 1, null,
        "任务已被用户中断,已执行到第 " + iteration + " 轮", null, null));
    return "";
}

中断后返回已完成的部分结果,且状态、checkpoint 都留在任务记录里。

SQLite 持久化 + 重启恢复LongTaskStore 把任务(含 steps / logs / token 统计)落进 long_tasks 表,应用启动时全量恢复。这里有个容易被忽略的工程细节:JDBC Connection 非线程安全,多任务并发写共享长连接会看到 database is locked,所以它的设计是每个操作临时新建连接 + PRAGMA busy_timeout = 5000 + WAL,读写互不阻塞:

st.execute("PRAGMA busy_timeout = 5000");
st.execute("PRAGMA journal_mode = WAL");

恢复时,运行中/排队中的任务(进程没了没法续跑)被标记为"应用重启,任务中断";完成后超过保留期(默认 30 天)的任务由 TaskCleanupScheduler 定时清理——隐私保护和 DB 防膨胀一箭双雕。续跑就是按同参数重新 submit(),拿到新任务 ID,旧任务原样保留供对比。

上下文压缩:按完整消息开销估算,而不是只算文本

长 Agent 循环最隐蔽的炸弹是上下文膨胀。spring-harness 的 ContextGuard 有两层防护,但真正区别于"朴素截断"的,是它的开销估算口径

public static int estimateMessageChars(Message m) {
    int len = safeLen(m.getText());
    if (m instanceof AssistantMessage am && am.getToolCalls() != null) {
        for (var tc : am.getToolCalls()) len += safeLen(tc.name()) + safeLen(tc.arguments());
    }
    if (m instanceof ToolResponseMessage trm && trm.getResponses() != null) {
        for (var tr : trm.getResponses()) len += safeLen(tr.responseData());
    }
    return len;
}

必须把 toolCalls 的 arguments 和 ToolResponse 的 responseData 都算进去。代码注释里写得很直白:曾经只按 getText() 估算,最终造成一次约 63 万 token 的上下文超限事故。要说清楚的是,这个口径是字符数估算而不是真正的 tokenizer——它的价值在于把 tool call / tool response 的开销都覆盖进来,让估算更接近真实上下文成本,这已经足够支撑裁剪决策。

超阈值后怎么裁?不是简单丢掉最旧消息,而是折叠成一轮轻量摘要——保留"工具名 + 截断的参数/结论 + 思考",零 LLM 调用,成本为零:

【早期工具执行记录·已压缩】
- 思考: 用户要分析这个 CSV,先看结构
- 调用 read-file(热度最高的.csv: 0,1000)
  → read-file: 1000 行 × 8 列,含 date/amount...

关键约束是保留 system(0) 和 user(1)——任务描述永远不丢;以及防套娃折叠:折叠出的摘要带 【早期工具执行记录·已压缩】 前缀,再折叠时不再压缩而是直接丢弃(价值最低),不会层层套娃把摘要又压一遍。

ContextSummaryConfig 还留了更高一级的开关:CONTEXT_SUMMARY_ENABLED=true 时,折叠的摘要由 LLM 语义生成(默认跟随主模型,上限 4000 字符),语义保真度更高,代价是每次裁剪多一次 LLM 调用、在免费模型限流下更慢——所以默认关闭,走零成本轻量折叠。这个"默认零成本、可选高质量"的分层设计很实在。

ContextGuard 还兼管两件收尾事:单次工具输出超过 8000 字符截断(防止读大文件塞爆上下文),以及 LLM 最终输出里剥掉文本形式的工具调用标记<tool_call>/invoke/function_call/|<tool_calls_section_begin|> 等)——部分模型会把这些内部指令以文本形式混进回答,不能展示给用户。

RAG:上传校验到切分持久化一条链

RAG 的工程问题不只在检索,更在上这道闸。上传 PDF/TXT/MD/代码,RagService.validateUpload 做了四层校验:

  1. 后缀白名单(.pdf/.doc/.docx/.md/常用代码文件等)
  2. 大小上限 50MB
  3. MIME 软校验(浏览器常给 octet-stream,只做参考)
  4. 魔数 + 内容校验——这是防伪造后缀的关键:PDF 头必须是 %PDF-、DOCX 必须是 ZIP 的 PK\x03\x04、DOC 必须是 OLE 的 D0 CF 11 E0,文本类则读前 8KB 检查可打印字符比例 ≥ 0.85,把"把 .exe 改名成 .pdf"这类伪装直接拦在门外。

切分按文件类型分派,而非一刀切:

  • .md/.markdown → MarkdownTextSplitter:按标题层级切分,保住 Markdown 语义
  • .pdf/.doc/.docx → ParagraphTextSplitter:按段落切分
  • 其他 → TokenTextSplitter:按 token 数切分

向量化后进 SimpleVectorStore,并且实时持久化三份文件——向量库、文档注册表、文档内容(用于预览),addDocument/deleteDocument 后立刻落盘,重启自动从文件加载。还有一个对个人系统很重要的 clearAll():一键清空所有文档和向量,为个人系统提供明确的数据删除能力——可以理解为个人系统里的"机器遗忘"能力,评论区常问的隐私问题它主动给了答案。

ReAct 与 PSE:手动控制工具执行,才是多轮的开始

ReAct:为什么必须禁用框架的自动工具执行

Spring AI 默认会替你执行工具调用,但对要实现多轮 ReAct 循环的自己恰恰要关掉它——否则每一轮模型调用的工具被框架偷偷执行完、结果塞回模型,你的循环根本控制不了。ReActAgentService 的做法是:

ToolCallingChatOptions.builder()
    .toolCallbacks(getAllTools())
    .internalToolExecutionEnabled(false)  // 框架不落地,交给我们手动执行
    .maxTokens(maxTokens)

于是循环结构完全在掌握之中:思考 → 调用工具 → 观察结果 → 再思考,最多 MAX_ITERATIONS = 10 轮强制结束。每轮:

  • 工具结果先经 ErrorSanitizer.sanitizeContent 脱敏(过滤 API Key / token / secret,防止敏感凭证进上下文或落盘),再经 ContextGuard.truncateToolOutput 截断;
  • 工具往返加入历史后调 ContextGuard.trimMessages 压缩,防膨胀;
  • 每轮开头检查取消信号,可协作式中断;
  • 结束后 memoryService.extractAndStore 异步抽取长期记忆。

工具集合在 getAllTools() 里用 LinkedHashMap 按名字去重、本地工具优先、MCP 工具补位——同一个工具名绝不会同时出现两份。

PSE:Planner 分解、按依赖分层、无依赖并行

PseOrchestrator 把任务交给 Planner → Specialist → Evaluator 三角色。它有两个"聪明的懒"的设计:

按依赖分层并行。Planner 分解出子任务后,buildDependencyLayers 把无依赖的任务归到同一层,同层任务用 CompletableFuture 并行跑,层层推进(出现循环依赖时,剩余任务整体归入一层兜底执行):

while (!remaining.isEmpty()) {
    // 能完成依赖的 → 该层;同一层无依赖 → 可并行
    for (PseTask task : remaining)
        if (deps empty || completedNames.containsAll(deps)) currentLayer.add(task);
    ...
}

简单任务快速通道。Planner 只分解出 1 个子任务时,直接执行 + 评审,跳过整体评审和最终交付的完整编排,省掉一次 LLM 往返。

每个子任务同样走 Specialist 执行 → Evaluator 按验收标准(AC)评审 → 未通过重试(最多 2 次)。整体还有两道护栏:MAX_TOTAL_ITERATIONS = 15 防无限循环,pse.timeout-seconds = 90 默认整体超时——超时不硬失败,返回已完成的部分结果,用户至少拿到能用的东西而不是一句空错误。

结果

这五个点,其实在回答同一个问题:一个给自己用的 Agent 系统,怎么才敢真正放它去跑

  • Docker 沙箱让代码执行默认与宿主环境隔离;
  • SQLite 长时任务让几十分钟的多 Agent 协作关掉页面也不丢、还能续跑;
  • 按完整消息开销估算的上下文压缩让十轮循环不爆窗口;
  • RAG 的魔数校验把伪造文件挡在上传那一刻;
  • 手动控制工具执行的 ReAct 和按依赖分层的 PSE,让"多轮、多角色"真正可控。

这些设计不是为了堆功能,而是一次次真实运行踩坑后的结果:容器隔离换来确定性,SQLite WAL 解决并发落盘,ContextGuard 来自 63 万 token 的真实事故,ReAct/PSE 则把 Agent 的执行过程重新收回控制范围。

对我来说,Harness 的价值不是让 Agent"能跑",而是让它在真实任务里敢跑、可停、可恢复、可控制

Docker 沙箱每次跑代码都起新容器,不慢吗?

是的,每次都有容器启动开销,多语言、禁网、只读文件系统的隔离收益对齐了个人使用频率,换取的是代码执行与宿主环境默认隔离的确定性。--rm 自动销毁 + tmpfs /tmp,不留任何中间态。

为什么长时任务要 SQLite 而不是内存存储?

进程重启不丢任务是硬需求。LongTaskStore 用 WAL + busy_timeout + 每操作独立连接解决并发写锁;运行中任务重启标记中断、已完成任务按保留期自动清理。

上下文压缩的"折叠"和直接丢弃旧消息有何区别?

折叠保留”工具名 + 截断参数/结论 + 思考”一行摘要,零 LLM 调用;只算 getText() 会漏掉 toolCalls arguments 和 tool response 的真实开销(曾造成一次约 63 万 token 的上下文超限事故)。可选的 LLM 语义摘要默认关闭。

ReAct 为什么要禁用框架的自动工具执行?

Spring AI 默认自动执行工具会把每轮结果悄悄塞回模型,你自己就失去了对多轮循环、中断、上下文压缩、工具脱敏的控制。手动执行(internalToolExecutionEnabled=false)才让整个循环透明可控。

PSE 和 ReAct 各自适合什么场景?

ReAct 适合”单 Agent 多轮思考+调用工具”的连贯任务;PSE(Planner-Specialist-Evaluator)适合可分解的多子任务协作,且同层无依赖子任务自动并行,配 90 秒整体超时和最大重试次数保底。

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

商店 Web Chat Nsbp 关于 隐私政策

@ 2026 ESN
沪ICP备2024079226号-1   沪公网安备31010502007082号