ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

ReAct Loop驱动的多Agent协同系统设计与崩溃复盘

ReAct Loop驱动的多Agent协同系统设计与崩溃复盘 1. 这不是又一个“Agent框架”Demo而是一次真实系统生命周期的复盘我第一次看到 AgentTeams 这个名字是在去年底一个内部技术分享会上。当时主讲人没放PPT只贴了三行代码和一张拓扑图说“这不是我们想推的产品是跑着跑着自己长出来的系统。”台下十几号人面面相觑——没人想到三个月后它会成为整个研发中台的调度中枢更没人想到半年后它会被整体下线代码归档进一个叫legacy/agent-teams-dead的目录里。今天这篇不讲高大上的架构图也不列一堆抽象的“能力矩阵”就带你钻进这个叫 AgentTeams 的实验系统里从它第一行日志开始看它怎么用 ReAct Loop 撑起多角色协同怎么靠 RuntimeRunner 实现沙箱隔离又怎么在 MyCodeAgent 的调用链里突然卡死、反复超时、最终被判定为“不可维护”。你可能正在设计自己的 Code Agent 系统也可能刚踩进多智能体协作的坑里——别急着抄开源库先看看一个真实跑起来、又真实挂掉的系统它的骨架是怎么搭的血管是怎么堵的心跳是怎么停的。核心关键词就这五个Code Agent、AgentTeams、MyCodeAgent、RuntimeRunner、ReAct Loop。它们不是并列概念而是嵌套咬合的关系ReAct Loop 是神经反射弧RuntimeRunner 是肌肉组织MyCodeAgent 是单个神经元AgentTeams 是由多个 MyCodeAgent 组成的临时作战单元而 Code Agent 是整个系统的统称。适合谁读不是纯理论研究者也不是只想调 API 的新手而是正卡在“单个 Agent 跑得通多个 Agent 一协同就崩”的工程师——你大概率已经写过带 tool calling 的 LLM wrapper也试过 LangChain 的 AgentExecutor但当你把三个 Agent 放进同一个任务流发现它们开始互相覆盖 context、抢夺 token budget、甚至把彼此生成的中间文件删掉时这篇就是为你写的。2. 项目整体设计与思路拆解为什么非得造一个“团队”而不是堆更多 Agent2.1 问题不是“能不能做”而是“做了之后谁来收尸”我们最初的需求非常朴素让 AI 自动完成一个跨仓库的代码重构任务。比如要把某个公共工具类里的parseJson()方法升级成支持流式解析这需要同时改 A 服务的调用方、B 服务的兼容层、C 项目的文档示例还要跑一遍全量测试。单个 MyCodeAgent 做不了——它没有全局视图不知道 A 服务改了会影响 B 服务的 mock 数据格式它也没有权限边界不能既改代码又删旧文档它更没有状态同步机制A 服务改完提交了B 服务还在基于旧版本生成 patch。于是团队里有人提“那就串行跑三个 Agent第一个改 A第二个改 B第三个改 C。”我当场否了。不是技术上做不到而是运维上会死人。实测过三个独立 Agent 串行执行平均失败率 43%失败原因 78% 是“前序 Agent 修改了依赖后序 Agent 没感知到”。比如第一个 Agent 把utils/json_parser.py重命名成了json_stream_parser.py第二个 Agent 还在 importjson_parser直接报 ModuleNotFoundError。这不是模型能力问题是系统级的状态割裂。2.2 AgentTeams 的诞生不是加功能而是加“约束”AgentTeams 的设计哲学很反直觉它不追求让 Agent 更聪明而是让它们更“笨”——强制它们在固定规则下协作。核心就三条铁律角色绑定不可变每个 MyCodeAgent 在加入 Teams 时必须声明唯一角色如CodeReviewer、TestGenerator、DocUpdater且运行中不能切换。不是为了装模作样而是为了让 RuntimeRunner 能预分配资源——CodeReviewer需要读取整个 repo 的 ASTTestGenerator需要预留 2GB 内存跑 pytestDocUpdater只需开一个 Markdown 解析器。如果角色可变资源就得按峰值配成本翻三倍。ReAct Loop 必须带“心跳帧”标准 ReAct Loop 是 “Thought → Action → Observation → …”AgentTeams 强制在每个 Observation 后插入一个Heartbeat帧内容固定为{ role: CodeReviewer, step: 3, progress: 0.62, blocked_by: [TestGenerator: waiting for test coverage report] }。这个帧不参与推理只供 Teams 调度器读取。实测下来光靠这个帧任务平均阻塞时间从 17 分钟降到 2.3 分钟——因为调度器能主动 kill 掉卡在waiting for...超过 90 秒的 Agent而不是等 timeout。RuntimeRunner 是唯一入口也是唯一出口所有 MyCodeAgent 的输入输出必须经由 RuntimeRunner 中转。它干三件事一是做沙箱隔离每个 Agent 进程用独立 user namespace cgroups 内存限制二是做 IO 代理Agent 想读文件RuntimeRunner 先检查它是否有该路径的 read 权限 token三是做 action 标准化Agent 发run_command(git commit -m fix)RuntimeRunner 拦下来改成git_commit(repo_path/tmp/workspace/a-service, messagefix, authoragent-teams)。这听着繁琐但避免了 90% 的“Agent 乱删文件”事故。提示很多人以为多 Agent 协作的关键是“通信协议”其实真正的瓶颈是“资源仲裁”。AgentTeams 没设计任何 fancy 的消息总线它的调度器本质就是一个带优先级队列的文件锁管理器——当TestGenerator想写/tmp/workspace/test_report.json时RuntimeRunner 先查这个文件是否被CodeReviewer锁定锁表结构就存在 Redis 里key 是lock:file:/tmp/workspace/test_report.jsonvalue 是{holder: TestGenerator, expires_at: 1715234567}。简单粗暴但稳定。2.3 为什么选 ReAct Loop 而不是 Plan-and-ExecutePlan-and-Execute 框架如 OpenAI 的 Function Calling在单步决策上确实更稳但它有个致命缺陷Plan 阶段必须预知所有可能分支。在代码重构场景里这根本不可能。比如CodeReviewer发现某处调用parseJson()传的是bytes而不是str它得立刻决定是改调用方还是改函数签名——这个决策依赖TestGenerator生成的覆盖率报告而报告还没出来。Plan-and-Execute 会让整个流程卡在 Plan 阶段直到超时。ReAct Loop 的优势在于“边走边看”CodeReviewer先执行read_file(a-service/main.py)看到 bytes 参数再发ask_team(TestGenerator, generate_coverage_for_this_call)等 Observation 返回后再决定下一步。这种异步反馈链正是 AgentTeams 能跑起来的底层支撑。我们做过对比测试同样任务Plan-and-Execute 平均耗时 8.2 分钟ReAct Loop AgentTeams 是 4.7 分钟失败率低 31%。3. 核心细节解析与实操要点RuntimeRunner 如何把“沙箱”变成“牢房”3.1 RuntimeRunner 的三层沙箱从进程隔离到语义拦截RuntimeRunner 不是简单的 Docker 封装它构建了三层防护L1OS 层隔离每个 MyCodeAgent 运行在一个独立的 user namespace 中UID 映射为0 - 10000agent_id。这意味着 Agent 进程里os.getuid()返回 0但它实际在宿主机上是 UID 10001。更重要的是它看不到其他 Agent 的进程——ps aux只显示自己那几个线程。我们不用 rootless Docker是因为 Docker 的 PID namespace 隔离不够彻底曾发生过 Agent 通过/proc/[pid]/fd/读取其他容器文件的事故。L2IO 代理层所有文件操作都走 RuntimeRunner 的 proxy。Agent 调用open(/home/user/project/src/utils.py, r)RuntimeRunner 拦截后先查权限表{ /home/user/project/src/utils.py: [CodeReviewer, DocUpdater] }确认当前 Agent 角色在白名单内再把路径映射为/tmp/runtime-runner/agent-123/workspace/src/utils.py最后用宿主机权限打开。关键点在于Agent 永远不知道真实路径它看到的全是/home/user/...这种假路径。这样即使 Agent 被 prompt 注入执行os.system(rm -rf /home/user/)实际删掉的只是自己 workspace 下的文件。L3Action 语义层这是最容易被忽略的一层。Agent 发action: run_command, args: { cmd: git add . }RuntimeRunner 不直接执行而是解析 cmd 字符串识别出这是 git 操作再根据当前 Agent 角色做校验CodeReviewer角色允许git status和git diff但禁止git add和git commitTestGenerator只允许pytest相关命令。我们用了一个极简的 DSL 来定义规则# rules/git_rules.py GIT_PERMISSIONS { CodeReviewer: [status, diff, log], TestGenerator: [pytest, coverage], DocUpdater: [mdformat, markdownlint] }如果 Agent 发了违规命令RuntimeRunner 返回{error: Permission denied: git add (allowed: status, diff)}而不是静默失败。这点很重要——它让调试变得可追溯而不是让 Agent 在黑盒里无限 retry。3.2 MyCodeAgent 的“瘦身”改造去掉一切非必要能力原始 MyCodeAgent 是个全能选手能读代码、能写代码、能跑测试、能查 Git 日志。放到 AgentTeams 里这反而成了毒药。我们给每个角色做了精准裁剪CodeReviewerAgent保留AST 解析用 LibCST、代码相似度计算用 difflib.SequenceMatcher、跨文件引用分析用 pyan3 生成 call graph移除所有文件写入能力、所有 shell 命令执行能力、所有网络请求能力包括访问 internal API新增ask_team(role, question)工具函数底层调 RuntimeRunner 的 team RPC 接口TestGeneratorAgent保留pytest runner 封装、coverage 数据解析、test case 生成模板Jinja2移除代码修改能力、Git 操作能力、文档生成能力新增wait_for_file(path, timeout300)工具函数用于等待CodeReviewer输出的 analysis.jsonDocUpdaterAgent保留Markdown AST 解析用 markdown-it-py、代码块提取、示例替换逻辑移除所有 Python 代码执行能力、所有 AST 分析能力、所有测试能力新增get_latest_commit(repo_path)工具函数用于获取本次重构的 commit hash 插入文档这种裁剪不是为了“安全”而是为了“可预测性”。当CodeReviewer突然开始生成测试用例或者DocUpdater开始尝试 import numpy你就知道一定是 prompt 注入或模型幻觉失控了——而在统一 Agent 里这种异常行为会被淹没在海量日志中。3.3 ReAct Loop 的“心跳帧”实现一行 JSON 救了一半的超时心跳帧不是额外加的功能而是对 ReAct Loop 协议的微小但致命的改造。标准 Loop 的 Observation 是模型返回的任意文本比如Observation: Found 3 usages of parseJson() in a-service/main.py. All pass bytes.AgentTeams 要求 Observation 必须是 JSON 对象且必须包含heartbeat字段{ observation: Found 3 usages of parseJson() in a-service/main.py. All pass bytes., heartbeat: { role: CodeReviewer, step: 2, progress: 0.45, blocked_by: [], ready_for: [TestGenerator] } }RuntimeRunner 在收到 Observation 后先 JSON 解析验证heartbeat结构再更新 Redis 中的 agent 状态。调度器每 5 秒扫描一次所有 heartbeat如果发现blocked_by非空且持续超过 90 秒就触发干预查blocked_by列表比如[TestGenerator: waiting for test coverage report]查TestGenerator的最新 heartbeat确认它是否真的卡住了progress 0.1且last_update now - 60s如果确认卡住向TestGenerator发送force_resume信号强制它跳过当前步骤返回默认 fallback observation这个机制让我们把“死锁”从不可恢复的故障变成了可干预的 transient error。上线后因死锁导致的任务失败率从 22% 降到 1.8%。代价是每个 Agent 的 prompt 多了 87 行 instruction但值得。4. 实操过程与核心环节实现从启动到崩溃的完整链路4.1 启动一个 AgentTeams 实例5 分钟部署脚本AgentTeams 的部署不是 Kubernetes 大阵仗而是一个start.sh脚本驱动的轻量级服务。核心只有三个进程teams-scheduler主调度器监听 Redis 的task_queue分发任务给各 Agentruntime-runner沙箱网关每个 Agent 连接一个独立 socketmycode-agent-*按需启动的 Agent 进程角色由启动参数指定启动脚本精简到 32 行已脱敏#!/bin/bash # start.sh export REDIS_URLredis://localhost:6379/0 export WORKSPACE_ROOT/tmp/agent-teams-workspace # 启动调度器后台 nohup python scheduler.py --port 8001 /var/log/agent-teams/scheduler.log 21 # 启动 RuntimeRunner后台 nohup python runtime_runner.py --port 8002 --max_memory_mb 2048 /var/log/agent-teams/runner.log 21 # 启动三个 Agent前台便于调试 python mycode_agent.py --role CodeReviewer --runner_host localhost --runner_port 8002 python mycode_agent.py --role TestGenerator --runner_host localhost --runner_port 8002 python mycode_agent.py --role DocUpdater --runner_host localhost --runner_port 8002 echo AgentTeams started. Check logs at /var/log/agent-teams/关键细节--max_memory_mb 2048是 RuntimeRunner 的硬限制不是建议值。它用setrlimit(RLIMIT_AS, 2048*1024*1024)直接设进程虚拟内存上限比 cgroups 更底层、更可靠。所有 Agent 进程用启动但不加nohup——因为一旦崩溃调度器会自动拉起新实例不需要守护进程。日志路径固定方便tail -f /var/log/agent-teams/*.log实时追踪。我们试过用 journalctl结果发现 Agent 崩溃时日志丢失率高达 15%换成文件日志后归零。4.2 一次典型任务执行重构parseJson()的 17 步链路以升级parseJson()为例完整链路如下省略中间重复步骤teams-scheduler从task_queue拿到任务解析出目标方法utils/json_parser.py:parseJson调度器向CodeReviewer发送初始 ThoughtAnalyze all usages of parseJson() across reposCodeReviewer执行read_repo(a-service)RuntimeRunner 返回 AST 结构化数据CodeReviewer发现 3 处调用其中 1 处传bytes生成 Observation heartbeatready_for: [TestGenerator]调度器看到ready_for向TestGenerator发送 ThoughtGenerate tests covering bytes input for parseJson()TestGenerator执行run_command(pytest --collect-only)RuntimeRunner 拦截校验权限后执行TestGenerator返回 coverage 报告heartbeat 中blocked_by: [CodeReviewer: waiting for new signature]调度器查CodeReviewer状态发现它卡在step: 3, blocked_by: [TestGenerator: waiting for report]形成循环等待触发心跳干预向TestGenerator发force_resume它返回 fallback observationGenerated 5 test cases, coverage: 82%CodeReviewer收到 report生成新函数签名def parseJson(data: Union[str, bytes], stream: bool False) - Any:CodeReviewer执行write_file(utils/json_parser.py, new_content)RuntimeRunner 校验权限CodeReviewer有 write 权限后写入CodeReviewerheartbeat 更新ready_for: [DocUpdater]DocUpdater读取README.md提取旧示例代码块DocUpdater调用get_latest_commit(/tmp/workspace)拿到 commit hasha1b2c3dDocUpdater生成新示例插入 commit hash执行write_file(README.md, new_content)所有 Agent heartbeat 中progress: 1.0,blocked_by: []teams-scheduler汇总结果生成 final report任务结束整个过程耗时 4 分 12 秒日志共 1.2MB。最耗时的环节是第 6 步pytest --collect-only1.8 秒其次是第 11 步文件写入0.9 秒。我们后来把pytest改成pytest --collect-only --tbno节省了 0.6 秒——这种微优化在高频任务里每天能省 2.3 小时 CPU 时间。4.3 系统崩溃的临界点当心跳帧开始“说谎”AgentTeams 的死亡不是突然的而是一系列微小退化累积的结果。崩溃前 3 天的日志里出现了 3 个征兆征兆一heartbeatprogress字段开始漂移正常时progress是严格递增的 float0.0 → 0.3 → 0.6 → 1.0。崩溃前TestGenerator的 heartbeat 出现progress: 0.42, then 0.39, then 0.41。原因是它的coverage计算逻辑依赖pytest的随机顺序而pytest在不同环境下收集 test case 的顺序不一致导致 progress 计算基准漂移。调度器误判为“倒退”频繁触发 force_resume。征兆二blocked_by列表出现循环引用日志里开始出现blocked_by: [CodeReviewer: waiting for TestGenerator, TestGenerator: waiting for CodeReviewer]。这不是死锁而是两个 Agent 在同一秒内更新 heartbeatRedis 的SET操作覆盖了对方的值导致双方都看到对方的旧状态。我们没加分布式锁因为觉得“每秒最多一次 heartbeat冲突概率太低”结果在高并发任务下冲突率飙升到 12%。征兆三RuntimeRunner 的IO proxy延迟突增正常延迟是 8~12ms崩溃前一周read_file操作的 P95 延迟跳到 217ms。根因是RuntimeRunner的文件缓存用了LRU cache但没设 size limit随着任务增多缓存膨胀到 2.1GBGC 频繁触发。我们查日志时才发现RuntimeRunner进程的 RSS 内存从 380MB 涨到 2.4GB而ps aux显示它只占 CPU 12%明显是内存瓶颈。这三个征兆单独看都不致命但叠加起来让调度器的决策逻辑全面失准它以为 Agent 在倒退所以不停 force_resume它以为 Agent 在死锁所以反复重启它以为 IO 慢是网络问题所以加大 timeout——结果是任务失败率从 5% 一路飙到 68%运维同学每天花 4 小时手动 rescue 失败任务。5. 常见问题与排查技巧实录那些文档里不会写的坑5.1 问题速查表从现象到根因的 7 个典型故障现象可能根因排查命令修复方案Agent 启动后立即退出日志无错误RuntimeRunner socket 连接超时telnet localhost 8002检查runtime-runner是否启动端口是否被占用CodeReviewer读文件返回空内容文件权限未在 RuntimeRunner 白名单中redis-cli GET perm:/path/to/file用runtime-runner --add-permission /path/to/file CodeReviewer添加TestGenerator报ModuleNotFoundError: pytestRuntimeRunner 的 Python 环境未安装 pytestdocker exec -it runtime-runner bash -c pip list | grep pytest在 RuntimeRunner 镜像中RUN pip install pytest任务卡在step: 1超过 5 分钟CodeReviewer的 heartbeat 未发送redis-cli LRANGE heartbeat:CodeReviewer 0 -1 | head -n 5检查 Agent 的 prompt 是否漏了 heartbeat 指令DocUpdater修改的文件未生效RuntimeRunner 的 workspace 路径映射错误ls -l /tmp/runtime-runner/agent-*/workspace/检查start.sh中WORKSPACE_ROOT路径权限多个任务并发时 CPU 占用 100%RuntimeRunner 的 LRU cache 未限容pstack $(pgrep runtime-runner)在代码中加lru_cache(maxsize1024)teams-scheduler日志刷屏Task timeoutRedis 的task_queue消费延迟redis-cli LLEN task_queue增加teams-scheduler实例数或调大 Redis maxmemory5.2 实操心得三个血泪换来的“反模式”反模式一不要在 Agent prompt 里写“请按 JSON 格式返回”我们最初这么写结果模型返回{observation: ..., heartbeat: {...}}但有时会多一个逗号有时少一个引号JSON 解析直接 fail。后来改成强制指令Output ONLY valid JSON. No explanation, no markdown, no extra characters. If you cannot output JSON, output {error: invalid_json}。配合 RuntimeRunner 的 JSON schema 校验用jsonschema.validate错误率从 18% 降到 0.3%。反模式二不要信任 Agent 的self-reportCodeReviewer曾报告progress: 0.95但实际只分析了 2 个文件剩下 15 个没看。原因是它的 prompt 里写了“估算进度”模型就瞎估。我们改成硬编码progress files_analyzed / total_files在代码里算不交给模型。现在 progress 字段 100% 可信。反模式三不要用time.sleep()做等待TestGenerator早期用time.sleep(30)等待 coverage 报告结果 30 秒后报告还没好它就继续执行导致后续步骤全错。改成wait_for_file(/tmp/report.json, timeout300)底层用inotifywait监听文件创建事件响应时间从 30 秒降到 200ms。5.3 最后的“安乐死”如何优雅下线一个实验系统AgentTeams 下线不是git rm -rf就完事。我们做了三件事冻结新任务修改teams-scheduler对新任务返回{status: deprecated, migration_guide: use CodeFlow v2}并记录所有尝试提交的用户 ID发邮件通知。导出历史数据用脚本遍历所有 Redis keytask:*导出 JSON 格式的任务报告存入 S3。重点保留heartbeat序列这是唯一能还原协作过程的数据。留一个“墓碑进程”legacy-monitor.py持续监听task_queue如果还有老任务进来它不执行只打日志DEPRECATED_TASK_RECEIVED: {task_id} at {timestamp}并报警。这个进程跑了 47 天收到 3 个残留任务全部来自 Jenkins 的旧 pipeline。下线后我们开了个复盘会结论很实在AgentTeams 不是失败的设计而是过早暴露了 Code Agent 系统的本质矛盾——协作的复杂度永远大于单个 Agent 的智能度。它证明了 ReAct Loop RuntimeRunner 是可行的基座但没解决“角色间语义对齐”这个深层问题。比如CodeReviewer说“这个改动风险高”TestGenerator听不懂什么叫“风险高”它只认coverage 80%。这种语义鸿沟不是加个心跳帧就能填平的。我在实际操作中发现真正让多 Agent 协作落地的从来不是更炫的模型或更酷的框架而是更笨的约束、更细的监控、更狠的裁剪。AgentTeams 活了 142 天死了但它留下的 heartbeat 日志、RuntimeRunner 的权限表、MyCodeAgent 的角色定义现在全用在 CodeFlow v2 里。有些系统注定短命但它的尸体往往是下一代最好的养料。
返回列表