【Managed Agent】#1 初始化流程
用户进入Aviary Agent后,眼前只是一个输入框和对话界面。输入内容、按下回车,消息很快出现在对话中;随后,页面会实时展示Agent调用工具、执行任务的过程。 看上去,这不过是一次消息发送,远程Agent的执行结果被实时回传。但在这条看似简单的链路背后,系统需要完成的事情远比想象中更多。下面就从用户视角出发,沿着每一步实际发生的事,拆解整个流程涉及了哪些内容。
场景模拟
Saki是一个热爱技术的学生。一天,她注意到Anon Tokyo的Anon开发了一款名为Aviary Agent的云端Agent应用。按照介绍,用户只需要交代目标,Agent就可以在云端完成编程、检索、浏览等任务,甚至处理需要多步推进的长程任务。
Saki此前体验过类似“由Agent替自己操作电脑”的产品,因此很快理解了它的使用方式:打开一个对话,输入任务,然后等待结果从云端返回。她决定亲自试一试。下面是她创建的两段对话:
- Saki第一次创建对话,输入了:帮我写一个话剧的台词,班上的同学连AgentLoop都搞不明白,教我怎么高雅地说教她们。
- Saki第二次创建对话,输入了:帮我查询一下Lombok这个库里
@Slf4j的原理是什么。
~当用户首次输入内容后,此时没有会话id还是sessionid会被系统识别到这是一个创建会话的事件,所以会执行整个会话的初始化流程并把沙箱运行环境进行部署。关键点1.请求接收和配置和上下文装配;2.任务如何启动:根据用户的对话性质来选择不同的最小启动方式,系统默认懒部署,如果用户只是对话,就无需部署沙箱,直接调用模型返回对话内容(根据type是stop还是tool_call决定是否要创建沙箱)。如果模型确认需要部署沙箱进行工具调用,才会部署沙箱。3.沙箱的初始化以及生命周期维护,并给用户反馈沙箱创建结果。4. 用户侧交付:业务侧对流的监听,redis。~
前情提要:一段对话开始之前
在Saki打开Aviary Agent之前,Anon已经在后台做完了许多她看不见的准备工作。Anon先拥有一个属于自己的租户AnonTokyo,再在这个租户下创建了Aviary Agent这个应用;随后,她为应用发布了一套模板,选好模型Provider,也配置好了应用与Aviary之间通信所需的凭证。
这些事情并不会出现在Saki的屏幕上。有关租户、应用、模板和Provider凭证之间的关系,会在《B端管理模型》中单独展开。对她而言,Aviary Agent只是一个已经准备好的工具:登录、打开对话,然后把想做的事交代出去。真正复杂的部分,藏在这段对话开始之前。
Saki的第一个对话
1. 从用户请求到应用边界
Saki已经登录Aviary Agent。她点击“新建对话”,随后进入一个空白的聊天界面;输入框就在页面底部,等待她写下第一句话。
这一步看起来只是在界面上多开了一个聊天窗口,但业务侧已经以配置好的应用身份向Aviary平台发起了创建请求。Aviary创建这段对话并返回对话ID;业务侧则保存Saki与这段对话之间的归属关系。至此,页面才进入一个真正属于Saki的空白对话。
Saki随后输入并发送了第一句话:“帮我写一个话剧的台词,班上的同学连AgentLoop都搞不明白,教我怎么高雅地说教她们。”业务侧带着刚刚得到的对话ID,把这条消息转交给Aviary平台。Aviary将消息记录为这段对话的第一条输入,并交给后续流程继续处理。
2. 平台接收请求:从消息到标准事件
对Aviary而言,Saki发来的并不只是一段需要立刻交给模型的文字。平台先确认这条消息确实属于当前应用和这段对话,并将它持久化为对话中的一条事实。创建对话时,这段对话已经关联到一个应用,也记下了该应用当时使用的模板版本;因此,后续Worker总能沿着对话找到这次任务所需的配置,而不会被应用之后的配置修改悄悄影响。
这不是普通服务上线新版本那么简单。应用更新的往往是Harness里的提示词、工具说明和执行策略;这些内容会直接影响Agent如何理解已有上下文、如何规划下一步,以及如何调用工具。一个看似更好的新版本,如果在对话进行到一半时插进来,可能改写系统提示词中原有的执行顺序或工作流;它便会和这段对话已经进行到一半的实际步骤不一致,也可能让结果变差。因此,应用可以继续更新,但已经开始的对话仍应按创建时那套配置运行。
接下来,这条事实会被转换成平台内部能够统一理解的标准事件:某个对话收到了一条新消息。事件携带对话身份与用于去重的锚点,但不把完整的提示词和全部配置塞进消息体。它进入Kafka这一条内部事件主干后,由对应的Worker按对话顺序消费;Worker再读取这段对话已固定的配置和历史消息,装配本轮真正需要的上下文,并继续推进后续工作。
以Saki的第一条消息为例,一条标准事件大致会长成这样:
{
"type": "api.message.received",
"event_id": "api:{app_id}:{idempotency_key}",
"conv_id": 123,
"session_id": null,
"correlation_id": null,
"schema_version": 1,
"message_id": 456,
"policy": "QUEUE"
}可以把这条事件分成两部分来看。前六个字段每条事件都有:type表示当前事件类型;event_id用来识别重复消息;conv_id说明它属于哪段对话,也让同一段对话按顺序处理;session_id在执行环境还没创建时可以为空。
event_id的来源也很重要。业务侧发送消息时会带上自己的幂等键,用来说明“这是不是刚才那次请求的重试”;Aviary在受理事务中把应用身份和这个幂等键绑定,生成并保存对应的event_id。它不是Kafka生成的消息编号。这样,无论客户端重试、平台重新发布,还是Kafka发生重复投递,Worker看到的都是同一个event_id,便可以安全地把重复事件忽略掉。
再回到Saki的这条api.message.received事件。message_id说明它指向这段对话里的哪一条具体消息;消息正文已经存进数据库,Worker需要时再按这个ID把它取出来。每一类事件都有自己的处理流程;如果同一类事件有多种处理方式,就会用policy说明这一次该走哪一种。
这类消息事件的policy由调用方选择,一共有三种:默认的QUEUE表示先排队,等正在执行的Agent Loop完成后,再处理下一条消息;如果Agent已经在做事,STEER表示把新消息作为中途补充,在当前执行到可以衔接的位置后继续处理;SUPERSEDE表示用户改了主意,停止当前路径,回退到上一个检查点,再按新消息继续。Saki的第一条消息发出时,Agent还没开始工作,所以用默认的QUEUE即可。
其他事件也是同样的写法:先用type标明事件种类,再放这个种类自己需要的信息。比如工具调用完成会带工具调用ID和结果状态,沙箱事件会带运行环境相关的信息。系统先看type,再知道该读取哪些字段、走哪一条处理流程。
到这里,Saki的第一句话已经从一次用户操作,变成了一条平台能够稳定处理的标准事件。它会沿着发布链路进入Kafka,再由Worker按这段对话的顺序接手处理。为什么选用Kafka、事件如何可靠投递、如何保证顺序和幂等,会在《事件主干:为什么是Kafka》中单独展开;接下来要看的,是Worker拿到这条事件后,怎样补全上下文并开始这一轮Agent运行。
3. Worker接手这条事件
Kafka中的事件被Worker取到后,第一件事是检查它有没有处理过。Kafka允许重复投递,因此Worker会在本地的消费记录表中留下event_id;如果这条事件已经出现过,就不再重复执行。这个步骤很短,但它保证了Worker重启或Kafka重发时,不会让同一条消息重复启动两次。
接着,Worker会根据事件的type把它交给对应的处理逻辑。对于Saki的api.message.received,处理逻辑会找到这段对话对应的Session。前面创建对话时,这个Session已经准备好;现在Worker把Saki的首条消息标记为第一轮输入,并把Session从空闲状态切换为“等待模型回复”。这一步并不直接调用模型,而是先把“这一轮应该开始了”这件事可靠地记录下来,再创建一个可恢复的模型调用任务。
4. 组装上下文,调用模型
模型调用任务开始执行后,Worker才会组装真正要发给模型的内容。它沿着Session找到对话,再读取这段对话创建时固定的模板版本、已有消息记录和本轮消息。PromptAssembler负责把这些内容整理成模型能够理解的输入:系统提示词来自模板,历史对话来自消息记录,可用工具也由模板决定;模型Provider和凭证则按应用配置解析。
这一步也会考虑Prompt Caching。为了让immutable和stable前缀尽可能复用模型Provider的缓存,PromptAssembler不会把所有内容随意拼在一起,而是按变化频率分段:模板中的immutable提示词是template_immutable;同一段对话内相对stable的配置是session_revision;会随着对话推进而变化的摘要和消息上下文则属于dynamic部分,放在后面。前两段构成可缓存的immutable/stable前缀,平台会用模板版本和配置版本生成对应的缓存键;dynamic部分变化时,不会破坏前面已经可以复用的部分。这既减少重复计算,也能降低长对话的调用成本。
当前这套分段还是一个基础版本:它已经给immutable、stable和dynamic内容留出了位置,但还没有为每一种Provider实现完整的缓存策略。后续可以根据Provider的具体能力,在合适的位置设置显式缓存断点,并继续细化这三类内容的边界,让缓存命中率、缓存写入成本与上下文质量之间取得更好的平衡。
对话变长后,Worker还会做Context Compaction。它会把较早的完整轮次压成一份持久化摘要,下一轮只带上摘要以及之后的新消息。原始消息不会被删除,因此需要回退或排查时仍然有完整记录;摘要只是为了让模型在有限的上下文窗口中继续理解这段对话。
压缩也不是简单地把旧消息截掉。它要尽量保留已经做出的决定、尚未完成的任务、关键事实和工具执行结果,把冗长的历史转成信息密度更高的上下文。只有这样,Agent才能在很长的对话或多步任务中持续工作,而不会很快耗尽上下文窗口。这正是Long Horizon Task能够成立的基础能力之一。
模型没有提出工具调用时,就会直接不断返回文本;Saki的第一个对话走的正是这条路径,此时不需要启动沙箱。
5. 模型流:回调、虚拟线程与入账
模型返回的不是一整段最终答案,而是一连串增量内容。Aviary向模型网关发起的是gRPC流式调用:网络层每收到一部分内容,就通过回调把它交给Worker处理。Worker一边接收这些回调,一边按一定节奏把增量内容写入对话事件账;等模型流真正结束后,再把完整的助手消息和message.completed写入事件账,同时冻结一条供Worker内部继续处理的loop.llm_turn.completed事件。
与此同时,负责这轮任务控制流程的代码运行在Java 21虚拟线程中。它可以像普通同步代码一样等待整条模型流结束,但等待网络返回时,JVM会把这个虚拟线程挂起,不会让它长期占住一个平台线程。也就是说,模型流的增量靠回调接收;虚拟线程负责等待这一轮结束,并继续处理完成、失败或工具调用等后续步骤。这是同一条I/O流在平台内部的两种不同职责。
6. 已入账的结果怎样回到用户页面
每次增量内容写入事件账后,Worker会通知平台内部的实时层有新内容;但Aviary Agent业务侧不需要订阅Redis,也不需要和平台保持一条长连接。它只依赖一个很简单的接口:给定对话ID和游标,取回这个位置之后的新事件。
浏览器和Aviary Agent的Go服务之间仍然保持SSE长连接。Go服务会带着当前游标,定期向Aviary平台拉取这段对话的新事件;拿到新的增量内容后,再立刻写进SSE推给页面。于是,Saki看到的依然是Agent一点点“打出”回答的过程,但业务侧不必知道Worker在哪台机器上运行,也不必参与平台内部的Redis频道约定。
这里的关键是游标。对话事件账里的每一条内容都有递增的游标;每次拉取只返回这个游标之后的内容。这样,即使业务侧短暂断线或轮询中断,下一次也能从上次位置继续取,不会丢掉中间已经生成的内容。
这里需要区分两层“完成”。loop.llm_turn.completed是Worker内部事件:它会进入Kafka,让Reducer判断模型是要继续调用工具,还是已经可以收束这一轮。业务侧不会直接订阅Kafka。给业务侧和页面看的,是对话事件账中的message.completed;如果Reducer确认模型已经正常结束这一轮,还会追加turn.completed。Go服务在下一次按游标拉取时拿到这些事件,页面收到turn.completed后就知道Saki的任务已经结束,可以从“正在生成”切回正常状态。浏览器和Go服务之间的SSE连接可以继续保留,等待这段对话的下一条消息。
假设Saki发出问题后,没有一直停留在这个页面。她切到另一段对话,又创建了新的任务;这时,原来的模型调用仍会在后台继续,Worker照常把增量内容写进原对话的事件账。等她想起这个问题、重新打开原来的对话时,Go服务会带着页面上次读到的游标继续拉取,补回这段时间漏掉的内容;如果任务已经完成,她看到的就是完整回答和完成状态,而不是一段断在中间、无法判断结果的输出。
最终,Saki看到回答像打字机一样不断吐出:一句舞台提示、几句克制而尖锐的台词,逐渐拼成一段可以直接放进话剧里的对白。最后,页面停在这样一句话上: “连AgentLoop都搞不懂,汝等颅中所盛,莫非尽是甜面包呀?”
7. 这一条路径省略了什么
Saki的这个请求只需要生成文本,模型不会提出工具调用。因此,这一轮没有沙箱部署,也没有Workspace或快照操作。下一段对话会从模型决定调用工具的地方开始,进入另一条更长的执行路径。
Saki的第二个对话:Agent开始实际干活
第二次,Saki新建了一段对话,问道:“帮我查询一下Lombok这个库里@Slf4j的原理是什么。”她按下发送后,前半段流程与第一个对话没有区别:业务侧创建并保存对话、把消息交给Aviary;平台确认归属、记录消息,将它转换成标准事件;Worker再按对话顺序接手处理。前文已经拆过这条路径,这里不再重复;如果其中某一步看得不够清楚,可以回到“平台接收请求:从消息到标准事件”重新看一遍。
Saki按下回车,消息出现在对话里。短暂安静后,页面先展开了一块“思考中”的内容;紧接着,一张张工具调用卡片依次出现。Agent先用
Parallel搜索@Slf4j和Lombok的资料,找到官方API文档;随后用Agent Browser打开网页,阅读相关说明;确认还要核对真实实现后,又通过GitHub把Lombok仓库拉进Workspace,在代码里检索注解及其关联类。每张卡片结束时,页面都会补上一句简短的执行总结。最后,Agent没有只给出一段口头解释。它在Workspace里写了一个带Lombok注解的简短Java示例,再调用Lombok的
delombok把注解展开成普通Java代码;页面把两段代码并排展示,让Saki看到@Slf4j究竟替她生成了什么。随后,Agent又执行这个示例,确认代码确实可以跑通。等搜索结果、文档、源码和这次实际验证彼此印证,工具调用才停下来,Agent最后把结论整理成一段完整回答交给Saki。
从Saki的视角看,这和第一个对话很像:都是发出一句话,然后等待页面不断出现新的内容。但系统内部已经在这里分叉。上一次,模型可以直接写出一段台词,并以正常结束的方式返回答案;这一次,它需要先查看源码或资料,才能可靠地回答。对平台而言,这意味着模型没有结束这一轮,而是返回了一次tool_call:它向系统提出了一个明确的执行请求,接下来需要由Agent准备运行环境、调用工具,再把工具结果交回模型继续推理。
8. 环境准备:从tool_call到Docker Execute
这里有一个容易混淆的地方:Session并不是等到工具调用时才创建。它在Saki新建对话时就已经存在,用来保存这段对话的执行状态;现在要准备的,是它第一次真正需要的Sandbox运行环境。Worker收到tool_call后,会先把这次工具执行记录下来,再检查这个Session是否已经有可用的Sandbox。如果没有,才把部署请求交给DockerService。
每个Sandbox镜像里都会预放一个名为aviary的Harness CLI。它是沙箱内部的受控入口,不是模型本身。它的命令大致分成两层:一层负责生命周期,例如让容器待命、启动或停止受控后台进程、清理超时租约;另一层负责实际工具操作,例如执行命令、读写Workspace里的文件、读取Skill说明或发布产物。平台侧仍然决定“要做什么”,CLI只负责在边界之内把这件事安全地做完。
把所有容器内的工具操作收敛到这个入口,后面也会更容易加入Permission控制:无论是读文件、运行命令还是访问浏览器,都能在真正执行前经过同一个检查点。更长远地看,如果以后引入把更多决策和执行逻辑放进容器的“胖Agent”方案,也可以把这套CLI作为Hook嵌进Agent的工具调用链,在需要确认、拦截或阻塞时仍保留平台可控的边界。这里先不展开实现;重要的是,CLI让这条演进路径成为可能,而不是让每个工具各自绕开平台执行。
当前对外可见的CLI可以先把它理解成下面这11个常用命令:
aviary idle # 容器启动后的待命入口
aviary invoke # Worker调用的机器接口
aviary bash exec <command> # 执行Shell命令
aviary fs read <path> # 读取文件
aviary fs write <path> <content> # 写入文件
aviary artifact publish <path> # 发布产物
aviary skill list # 列出可用Skill
aviary skill read <name> # 读取某个Skill说明
aviary process start <command> # 启动受控后台进程
aviary process list # 查看受控后台进程
aviary process stop <lease-id> # 停止受控后台进程其中,idle和process这一组更接近运行环境的生命周期管理;其余命令则是在这个环境里完成具体工作。下面先从容器启动时一定会执行的idle开始。
DockerService会为这个Session选择可用的Sandbox Host,创建一个隔离的容器,并挂载它自己的Workspace和平台控制目录。容器一启动,最先运行的就是aviary idle。这里的idle不是说Agent正在“发呆”或思考,而是让CLI先初始化控制目录、清理上一次遗留的受控进程和租约,然后保持存活、进入“随时可以接活”的状态;没有新的工具调用时,它不做实际工作,有调用时才接受后续的Docker Execute。这样就不必为每一次很小的工具调用都重新启动一个完整容器。关于为什么选Docker、容器如何启动、暂停、恢复和回收,以及这些取舍如何影响内存和成本,会在《Sandbox选型与生命周期》中单独展开。
环境就绪后,Worker再通过Docker Execute进入这个容器,调用aviary invoke,把模型刚刚提出的工具名、参数、超时和执行ID交给CLI。invoke是CLI真正接活的命令:它校验这次请求,以受限的普通用户权限执行实际操作,并把标准化结果写成执行回执。Worker拿到回执后,将结果记入事件流,再交回模型继续这一轮推理;于是Saki才会在页面上看到某一次工具调用完成,以及下一次工具调用或最终回答接着出现。
这里也能看出当前CLI方案的通信边界。CLI不会主动和Aviary服务器维持RPC长连接,而是由Worker通过Docker Engine把请求送进invoke的标准输入;CLI把即时结果写到标准输出,同时把回执写入挂载的控制目录,Worker再读取回执并继续推动Agent Loop。容器里的工具进程若需要访问互联网,例如之后的网页搜索或拉取GitHub仓库,走的是它们自身受限的网络能力;它们并不因此获得平台内部的数据库、Kafka或Worker控制权。
至于CLI还能执行哪些具体能力,不必在这里一次讲完。接下来Saki的每一次tool_call,会自然带出它如何读取Skill、如何操作浏览器、如何拉取源码;如果未来需要短暂维持一个受控后台进程,再引入process.start和对应的回收机制即可。
9. 第一次tool_call:先读Skill,再执行搜索
Saki这条路径里,第一个有代表性的调用不是立刻执行一段搜索命令,而是先读取Parallel这个Web Search Skill的说明。模型先发出skill.read,CLI从预装Skill目录或Workspace中的应用级目录读取对应的SKILL.md;模型由此知道这个搜索能力能做什么、输入应如何组织、结果该怎样解释。读完说明后,模型才会发起下一次工具调用,真正执行Parallel的搜索CLI。这样,Skill承担“把能力和使用约定交给模型”的角色,CLI承担“在Sandbox中实际执行”的角色。
搜索类工具通常需要API Key或其他上下文,这是不能顺手写进镜像或长期放进容器环境变量里的。当前通用CLI运行时只注入Workspace、控制目录和运行用户等固定环境;仓库中已有的外部MCP凭证,也是由Worker在服务端按需解密后完成调用,而不是复制进Sandbox。因此,Parallel按工具注入凭证的通用机制目前还没有落地,本文把它作为下一步设计来说明。
一个更稳妥的做法是:应用配置只保存加密后的Secret引用;Worker在Permission检查通过后,按这一次tool_call解析需要的凭证,并只把它注入这一次docker exec启动的短命进程,而不是写进容器创建时的全局环境。执行结束后,这个进程和它的环境一起消失;Secret也不应进入Prompt、事件账、日志、执行回执或Workspace快照。若Provider支持短期令牌,进一步让Worker换取一次性或短时Token,会比把长期API Key交给容器更合适。
工具调用结束后,结果不会直接变成最终回答。CLI把本次命令的输出、退出状态和是否截断写进回执;Worker把它记入工具执行记录,并发布一条“这次Sandbox工具调用已完成”的内部事件。事件被Reducer处理后,会把这份结果作为一条tool_result写回当前对话;等当前等待中的工具都完成,系统才会启动同一轮的下一次模型调用。PromptAssembler重新组装上下文时,就会把这条tool_result放回模型输入里。
这里有一个很重要的边界:Workspace里的代码或日志不会自动整份塞进模型上下文。工具结果只带本次命令返回的有限输出;当前模型需要查看文件时,可以调用fs.read,也可以通过bash exec使用sed、tail或rg这类命令,只读取某一段、最后几行或匹配到的内容。后续还可以补充更专门的文件读取能力,让大文件、日志和目录检索不必总是借助Shell完成。无论采用哪种方式,原则都是只把这一步真正需要的片段带回模型:既避免大文件撑爆上下文,也保留“模型提出调用→平台执行→结果回到模型”的清晰闭环。模型随后可以继续调用另一个工具、利用结果写文件,或已经拥有足够证据开始回答;这个小循环会重复多次,直到模型以正常结束的方式收束这一轮。
后面的过程在Saki眼里推进得很快,一张张
Tool Call卡片依次出现:
fs.write:在Workspace中写入带@Slf4j的Java示例和运行说明。bash exec:调用Lombok的delombok,生成不使用注解的普通Java代码;随后编译并运行这个示例。fs.read:读取生成后的代码和运行日志,把两种写法的差异以及Logback是否生效带回给Agent。process.start:让示例短暂在后台运行,并为它登记一个可回收的租约;确认完毕后再停止它。artifact.publish:把源码、展开后的代码和运行说明打成ZIP,作为对话附件交给Saki。最后,页面出现一个可下载的文件。Saki点击下载后,就能在自己的电脑上继续使用这份示例。
10. 在Workspace里完成一次小实验
这几步用到的仍是同一个Harness CLI,只是每次tool_call选择了不同能力。写示例和读取结果时,Agent会使用fs.write与fs.read;需要创建目录、执行Lombok、编译或运行Java时,则使用bash exec。它们都只在这个Session自己的Workspace里发生,因此文件不会与其他用户或其他对话混在一起。
如果示例需要短暂地持续运行,例如为了观察Logback输出,Agent可以用process.start启动一个受控后台进程,并拿到对应的租约。命令将输出重定向到Workspace中的日志文件,之后再用fs.read读取日志;确认完毕后通过process.stop结束进程。即使Agent忘记收尾,idle也会根据租约回收超时进程,避免一个演示程序一直占着Sandbox资源。
11. 把结果交付给Saki
Artifact是另一条独立的分发链。Sandbox里的路径只在这个临时运行环境中有意义:Saki既不能、也不应该直接访问/workspace里的ZIP;容器可能已经暂停、迁移或被回收。artifact.publish的作用,就是把“Sandbox里的一个文件”变成“平台可以长期管理和授权分发的产物”。
具体来说,CLI不会把文件直接推给浏览器,而是先把本次发布的文件冻结到root拥有的控制目录。Worker随后通过Docker取走这份固定内容,写入Artifact目录并上传到对象存储,同时记录它属于哪个租户、应用和对话。对象写入、扫描和产物记录都完成后,平台才追加一条产物就绪事件;Aviary Agent拉到这条事件后,用Artifact ID把它渲染成对话附件。
对用户而言,稳定的是这个Artifact入口,而不是某台Worker或某个容器里的临时文件地址。Saki点击下载时,业务侧根据她的身份向平台请求该Artifact的下载地址;平台完成归属校验后,再返回一个可控的下载地址,例如短期有效的对象存储签名URL。这样文件可以安全地被反复下载,也可以迁移、重试或由不同Worker处理,而不必让浏览器和Sandbox之间建立一次脆弱的同步文件传输。