用 AutoGen 0.4 .NET 构建带状态持久化的事件驱动 Agent:HelloAgentState 实战解析
【免费下载链接】autogenA programming framework for agentic AI项目地址: https://gitcode.com/GitHub_Trending/au/autogen
事件驱动是 Agentic AI 应用的核心运行模式:Agent 订阅主题、监听事件、在处理中发出新事件,并最终把对话与工作流状态可靠地保存下来。本篇技术指南以仓库中 HelloAgentState 示例 为主干,剖析如何用 AutoGen 0.4 .NET SDK 编写一个 "Hello World" 级别的带状态 Agent 控制台程序。读完本文,你将掌握:如何自定义 Agent 类并通过TopicSubscription订阅事件、如何通过IHandle<T>编写事件处理器、如何使用 gRPC ProtoBuf 声明业务消息、如何在本地(Local)模式下启动 Agent 运行时并向 Agent 发消息,以及如何借助StoreAsync/ReadAsync完成 Agent 状态的生命周期管理与持久化。
示例概览:一个完整的"打招呼 + 存状态"闭环
HelloAgentState 是一个 .NET 控制台应用,它展示了一种典型的 Agent 应用拓扑:应用入口通过运行时(Runtime)向订阅了HelloAgents主题的HelloAgent发布一条NewMessageReceived事件;Agent 收到后生成问候文本、把当前工作流状态序列化后写入状态存储,再发出Output事件交由控制台输出;随后发出ConversationClosed事件通知会话结束,并回读状态、把workflow标记为Complete,最终通过Shutdown事件与IHostApplicationLifetime关闭进程。
整个应用把"事件收发"与"状态读写"两条主线串成闭环:状态写入发生在问候回复生成之后,状态回读与更新发生在会话关闭处理中,而进程何时真正退出,则取决于 Agent 从状态存储中读取到workflow == "Complete"。也就是说,状态不再是可有可无的装饰,而是决定应用能否优雅关闭的关键条件——这正是有状态 Agent 区别于纯转发式 Agent 的核心差异。
该示例项目的完整文件结构如下:
- HelloAgentState.csproj — 项目文件,声明目标框架与对 Core / Core.Grpc / Contracts 三个类库的项目引用;
- Program.cs — 程序入口与 Agent 定义;
- appsettings.json — 日志级别等宿主配置;
- Properties/launchSettings.json — 本地调试启动配置。
环境准备与运行
根据 示例 README 与 .csproj,运行本示例需要.NET 8.0 或更高版本(示例目标框架为net8.0)。项目采用Microsoft.NET.Sdk.Web+OutputType=Exe的组合,并引用了Microsoft.Extensions.Hosting,这意味着它本质上是一个托管于泛型宿主(Generic Host)之上的可执行应用。
在已克隆当前仓库的前提下,进入示例目录直接运行即可:
cd dotnet/samples/Hello dotnet run说明:
dotnet/samples/Hello目录下除了本示例,还包含 Hello 所在的多个兄弟项目(HelloAIAgents、HelloAgent、HelloAgentState)以及共享的 agent_events.proto,它们共用一套 ProtoBuf 事件契约。若只想运行本示例,可以cd dotnet/samples/Hello/HelloAgentState && dotnet run。
应用启动后,控制台会依次打印问候文本与状态快照,例如Hello World以及形如State: {data=We said hello to World, workflow=Active}的状态内容;当workflow变为Complete后进程自动退出。
核心概念:事件、主题订阅与事件处理器
事件处理器是应用的"心脏"
AutoGen 0.4 的事件驱动模型可以归纳为一句话:Agent 通过TopicSubscription订阅主题,运行时把该主题上的事件投递给对应 Agent,触发其IHandle<T>事件处理器执行。
示例 README 用一张流程图概括了 HelloAgentState 的完整消息流转:
可以看到,事件在 Agent 之间以"链式"方式流动:每收到一个新事件,Agent 的事件处理器就可能在内部产出新的消息并发布回事件总线,交给其他订阅方继续消费。事件处理器在整个链路中可以:
- 响应事件:读取
item中携带的业务数据; - 做副作用操作:例如把问候文本写入状态存储、把状态回读并解析;
- 发出新事件:向事件总线发布
Output、ConversationClosed、Shutdown等,驱动后续流程; - 参与生命周期控制:通过宿主生命周期对象(
IHostApplicationLifetime)决定何时终止整个进程。
用 IHandle 声明自己能处理哪些事件
示例 README 强调:Agent 的消息处理能力通过实现IHandle泛型接口来声明,且这些EventTypes是 gRPC ProtoBuf 声明的消息类型(默认定义于 agent_events.proto),编译器会将其生成为 C# 强类型类。接口契约位于 Contracts/IHandle.cs。
HelloAgentState 中的HelloAgent一共实现了三个事件处理接口,分别对应三类不同的业务时机:
[TopicSubscription("HelloAgents")] public class HelloAgent( IHostApplicationLifetime hostApplicationLifetime, [FromKeyedServices("AgentsMetadata")] AgentsMetadata typeRegistry) : Agent( typeRegistry), IHandleConsole, IHandle<NewMessageReceived>, IHandle<ConversationClosed>, IHandle<Shutdown> { // ... }IHandle<NewMessageReceived>— 处理入站问候请求,是整个工作流的起点;IHandle<ConversationClosed>— 处理会话结束信号,此时回读并展示状态、把工作流标记为完成;IHandle<Shutdown>— 处理关闭信号,轮询确认状态落盘为Complete后才真正停止宿主;IHandleConsole— 来自 IOAgent 的约定接口(定义于 ConsoleAgent/IHandleConsole.cs),让 Agent 具备向控制台输出的能力。
这些消息类型NewMessageReceived、ConversationClosed、Shutdown、Output均可在 agent_events.proto 中找到原始定义。
逐段拆解事件处理逻辑
下面我们对照 Program.cs 的完整源码,逐步分析三个事件处理器分别做了什么。
处理器一:收到 NewMessageReceived,问候并写入初始状态
public async Task Handle(NewMessageReceived item, CancellationToken cancellationToken = default) { var response = await SayHello(item.Message).ConfigureAwait(false); var evt = new Output { Message = response }; Dictionary<string, string> state = new() { { "data", "We said hello to " + item.Message }, { "workflow", "Active" } }; await StoreAsync(new AgentState { AgentId = this.AgentId, TextData = JsonSerializer.Serialize(state) }).ConfigureAwait(false); await PublishMessageAsync(evt).ConfigureAwait(false); var goodbye = new ConversationClosed { UserId = this.AgentId.Key, UserMessage = "Goodbye" }; await PublishMessageAsync(goodbye).ConfigureAwait(false); // send the shutdown message await PublishMessageAsync(new Shutdown { Message = this.AgentId.Key }).ConfigureAwait(false); }该处理器的职责链条值得注意:
SayHello只是拼装了一段带分隔线的问候字符串,返回"Hello World"之类的内容(真实业务中这里通常是调用 LLM 或执行工具的位置);- 构造一个字典状态
{ "data": ..., "workflow": "Active" },用System.Text.Json序列化后通过StoreAsync保存。AgentId(类型定义于 Contracts/AgentId.cs)作为状态的归属标识; - 依次发布
Output(让控制台显示问候)、ConversationClosed(宣告会话结束)、Shutdown(通知可以关闭)三个事件。
注意这里把事件"串行发出"而不是"串行等待处理结果"——三个事件的消费方各不相同,体现了事件总线的解耦特性。
处理器二:收到 ConversationClosed,回读状态并标记完成
public async Task Handle(ConversationClosed item, CancellationToken cancellationToken = default) { State = await ReadAsync<AgentState>(this.AgentId).ConfigureAwait(false); var state = JsonSerializer.Deserialize<Dictionary<string, string>>(State.TextData) ?? new Dictionary<string, string> { { "data", "No state data found" } }; var goodbye = $"\nState: {state}\n********************* {item.UserId} said {item.UserMessage} ************************"; var evt = new Output { Message = goodbye }; await PublishMessageAsync(evt).ConfigureAwait(true); state["workflow"] = "Complete"; await StoreAsync(new AgentState { AgentId = this.AgentId, TextData = JsonSerializer.Serialize(state) }).ConfigureAwait(false); }这是"有状态 Agent"的精髓展示:处理器并不依赖内存变量,而是通过ReadAsync<AgentState>(this.AgentId)从状态存储按 AgentId 回读之前写入的序列化 JSON,反序列化后拼进输出文本、打印到控制台,随后把workflow更新为Complete再写回。整个过程证明状态确实跨事件、跨处理器被正确保存与读取。
处理器三:收到 Shutdown,等待状态确认后关闭应用
public async Task Handle(Shutdown item, CancellationToken cancellationToken = default) { string? workflow = null; // make sure the workflow is finished while (workflow != "Complete") { State = await ReadAsync<AgentState>(this.AgentId).ConfigureAwait(true); var state = JsonSerializer.Deserialize<Dictionary<string, string>>(State?.TextData ?? "{}") ?? new Dictionary<string, string>(); workflow = state["workflow"]; await Task.Delay(1000).ConfigureAwait(true); } // now we can shut down... hostApplicationLifetime.StopApplication(); }Shutdown处理器展示了事件驱动与有状态模型结合的一个实用技巧:用状态而不是用共享变量来协调时序。由于ConversationClosed和Shutdown是先后发布到事件总线上的,二者被消费的相对顺序并不能在编写时百分之百保证(可能由不同订阅者/并行调度消费)。因此这里通过每秒轮询一次状态存储、直到读到workflow == "Complete"才调用hostApplicationLifetime.StopApplication()真正结束宿主。这正是"以持久化状态作为唯一事实来源(source of truth)"的工程实践。
SayHello:预留的业务扩展点
public async Task<string> SayHello(string ask) { var response = $"\n\n\n\n***************Hello {ask}**********************\n\n\n\n"; return response; }在真实项目中,SayHello通常会被替换为对 LLM/Agent 推理的调用,示例用一段格式化的字符串占位,把"响应生成"从"事件处理编排"中抽离出来,便于读者理解事件流。
启动运行时并发布第一条消息
AutoGen 0.4 将运行时抽象为可灵活启动的宿主(README 中称之为Microsoft.AutoGen.Agents.App),既可以在本地进程内以内嵌(local)方式运行,也可以连接分布式的 Agent Host(gateway)。Program.cs 顶部给出了"一次调用完成启动 + 发消息"的入口:
var local = true; if (Environment.GetEnvironmentVariable("AGENT_HOST") != null) { local = false; } var app = await Microsoft.AutoGen.Core.Grpc.AgentsApp.PublishMessageAsync("HelloAgents", new NewMessageReceived { Message = "World" }, local: local).ConfigureAwait(false); await app.WaitForShutdownAsync();这里有两个值得展开的设计细节:
local标志与环境变量切换:默认local = true,即把整个运行时(含事件总线、Agent 注册、状态存储)内嵌在当前进程里启动,适合开发调试与单进程演示;一旦检测到名为AGENT_HOST的环境变量(例如在 dev-team 之类的 Aspire 编排场景中由 AppHost 注入),则切换为local = false,连接远端 Agent Host,让 Agent 跑在独立进程中并通过 gRPC 通信。从源码结构看,该入口位于 Microsoft.AutoGen.Core.Grpc 类库中,本示例在 csproj 里通过ProjectReference引用了它;- 方法即入口:
AgentsApp.PublishMessageAsync("HelloAgents", message, local: local)第一参数是主题名(即HelloAgent上[TopicSubscription("HelloAgents")]声明的订阅主题),运行时会自动发现并实例化订阅该主题的 Agent,把消息投递过去,随后返回应用实例供WaitForShutdownAsync等待其退出。
关于appsettings.json(见 appsettings.json),示例将Microsoft.Orleans的日志级别设置为Warning,避免底层运行时噪声刷屏;开发时可在Properties/launchSettings.json中配置ASPNETCORE_ENVIRONMENT与端口。
主题订阅机制的实现细节
示例中 Agent 通过[TopicSubscription("HelloAgents")]声明订阅。README 说明了其底层机制:Agent 选择一个主题订阅(TopicSubscription),事件总线按主题路由事件;事件到达时,Agent 的事件处理器被以事件数据为参数调用。
仓库中与订阅、主题相关的契约类集中在 Contracts 目录:
- ISubscriptionDefinition.cs 与 IUnboundSubscriptionDefinition.cs — 订阅定义的抽象;
- TopicId.cs — 主题标识,Agent 与事件总线按主题名/类型匹配;
- AgentType.cs 与 AgentMetadata.cs — Agent 的类型注册与元数据,构造函数中以
[FromKeyedServices("AgentsMetadata")]注入的AgentsMetadata即用于这类注册查找。
从 HelloAgentState 的示例代码可以推断:TopicSubscription声明与运行时PublishMessageAsync("HelloAgents", ...)中使用的字符串主题名必须严格一致,订阅关系才得以建立。在运行于 Aspire/AgentHost 的分布式模式(参考 Hello 系列)下,同一主题还可被多个进程内的 Agent 共同订阅,实现多 Agent 事件协作。
自定义消息类型:gRPC ProtoBuf 是事件的"Schema 语言"
示例中的NewMessageReceived、ConversationClosed、Output、Shutdown均为默认契约中声明的消息,源码定义于 agent_events.proto:
message NewMessageReceived { string message = 1; } message ConversationClosed { string user_id = 1; string user_message = 2; } message Shutdown { string message = 1; }README 进一步指出:事件类型集合由 gRPC ProtoBuf 消息定义,经 gRPC 工具链生成 C# 强类型类;你完全可以自定义消息类型——在自己的项目中新建 .proto 文件,并在 .csproj 里引入 gRPC 工具。README 给出的自定义示例(例如定义NewAsk、ReadmeRequested两个业务消息):
syntax = "proto3"; package devteam; option csharp_namespace = "DevTeam.Shared"; message NewAsk { string org = 1; string repo = 2; string ask = 3; int64 issue_number = 4; } message ReadmeRequested { string org = 1; string repo = 2; int64 issue_number = 3; string ask = 4; }对应在.csproj中启用 Protobuf 编译:
<ItemGroup> <PackageReference Include="Google.Protobuf" /> <PackageReference Include="Grpc.Tools" PrivateAssets="All" /> <Protobuf Include="..\Protos\messages.proto" Link="Protos\messages.proto" /> </ItemGroup>引入Grpc.Tools(设为PrivateAssets="All",仅编译期使用)与Google.Protobuf运行时包后,messages.proto中定义的每个message都会生成强类型的 C# 类,可以直接作为IHandle<T>的泛型参数与PublishMessageAsync的载荷使用。仓库中对这一模式的大规模应用可参考 dev-team 示例:其消息契约集中在 Protos/messages.proto 与 Protos/states.proto,并在 DevTeam.Backend.csproj 中完成同样的 Protobuf 接入。
README 同时说明:Agent 之间发送的消息会包装为 CloudEvents 规范事件后送入事件总线;相应的事件结构定义可参考仓库根目录 protos/cloudevent.proto 与 protos/agent_worker.proto。
状态持久化:StoreAsync 与 ReadAsync
一次写、一次读的最小可用 API
README 用一个"最小 API"描述了状态的持久化能力——写入用Store,回读用Read<T>:
await Store(new AgentState { AgentId = this.AgentId, TextData = entry }).ConfigureAwait(false); // 读回 State = await Read<AgentState>(this.AgentId).ConfigureAwait(false);在本仓库当前版本的 Program.cs 中,对应实现为异步命名StoreAsync/ReadAsync,功能等价:
StoreAsync(new AgentState { AgentId = this.AgentId, TextData = JsonSerializer.Serialize(state) })— 以当前 Agent 的AgentId为键,把TextData字符串(JSON 序列化后的字典)写入状态存储;ReadAsync<AgentState>(this.AgentId)— 按AgentId读回AgentState对象,供TextData反序列化使用。
接口层设计:为何状态必须"可 JSON 序列化"
从仓库源码看,状态能力并非魔法,而是建立在 Contracts/ISaveState.cs 与 Contracts/ISaveStateMixin.cs 两个接口之上的:
ISaveState声明了保存/加载状态的最小契约;ISaveStateMixin<T>作为"混入(mixin)"层,把泛型类型T与 JSON 打通:其SaveStateAsync默认实现将SaveStateImpl()的结果通过System.Text.Json序列化为JsonElement;LoadStateAsync则反序列化回T并交给LoadStateImpl,反序列化失败时抛出InvalidDataException。
这正是 README 强调"The heart of an autogen application"之外、示例把状态字典直接JsonSerializer.Serialize后存入AgentState.TextData的原因——整个状态子系统建立在"状态必须是 JSON 可序列化对象"这一前提上。字段类型、嵌套结构只要满足该约束,均可进入状态存储。
AgentState:状态的载体消息
AgentState本身也是一个承载状态的消息结构(其携带AgentId用于标识归属、TextData用于存放任意序列化内容)。在示例中它被反复写/读,串联起NewMessageReceived → ConversationClosed → Shutdown三个处理器:启动会话时写入{data, workflow: Active},结束时读出展示并覆写为workflow: Complete,关闭前再确认读到Complete。
把这一模式抽象出来,就是有状态 Agent 的通用四步:
- 入站事件驱动(
NewMessageReceived); - 处理后持久化中间态(
StoreAsync,记录"已处理到哪一步"); - 在下游事件中回读并推进状态机(
ReadAsync→ 更新 →StoreAsync); - 以状态为准绳完成生命周期收尾(轮询直到
Complete才StopApplication)。
继承与组合:示例的第二个教学点
README 专门指出该示例还用于演示AutoGen 中的继承:HelloAgent继承自基类ConsoleAgent,后者提供WriteConsole方法。在当前仓库的演进版本中,IOAgent 目录下可以找到ConsoleAgent、FileAgent等 IO 基座类,配套接口见 IHandleConsole.cs 与 IHandleFileIO.cs。
需要说明的是:仓库中 HelloAgentState 最新 Program.cs 的HelloAgent直接继承Agent基类并单独实现IHandleConsole,而 README 中给出的是继承ConsoleAgent的早期写法。二者在概念上一脉相承——Agent/ConsoleAgent等基类把运行时接入、类型注册、IO 能力封装好,具体 Agent 只需关心自己的事件处理与业务逻辑。这种"基类提供基础设施、子类只写业务"的组合方式,让 Agent 可以只实现与业务相关的事件接口,而把消息分发、状态注入、生命周期等横切能力全部交给框架基座。基于同样的模式,你还可以在项目中对Agent做二次继承,沉淀出自己的领域基类(例如把AgentsMetadata注入、公共工具函数等下沉),这与 AutoGen 面向组合(composition)与继承双通道的设计是一致的。
小结
HelloAgentState 虽小,却完整覆盖了 AutoGen 0.4 .NET 事件驱动 Agent 开发的最小闭环:主题订阅声明 → 事件处理器链 → 运行时本地启动与消息发布 → ProtoBuf 消息契约 → 基于 StoreAsync/ReadAsync 的状态读写与生命周期协调。
以此为骨架,建议继续深入同一目录的兄弟示例了解更丰富的场景:
- HelloAgent — 不含状态的最简事件驱动版本,便于对照理解"状态"这一层新增了什么;
- HelloAIAgent — 接入 LLM 推理的 Agent 版本,可把本示例中
SayHello占位逻辑替换为真实模型调用; - GettingStartedGrpc 与 Microsoft.AutoGen 源码 — 深入理解 gRPC 传输与运行时实现;
- dev-team — 多 Agent、多进程、跨语言的完整工程化示例,展示了
AGENT_HOST模式与自定义 ProtoBuf 契约的规模化用法。
【免费下载链接】autogenA programming framework for agentic AI项目地址: https://gitcode.com/GitHub_Trending/au/autogen
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考