[AG-UI详解-04]如何构建一个兼容AG-UI协议的Agent

Agent是AG-UI协议的核心组件,负责处理请求和生成响应。它们为前端应用程序提供了一种标准化的方式,使其能够通过一致的接口与AI服务进行通信,而无需考虑底层实现方式。与我们针对某个Agent框架(MAF或者LangChain)谈论的Agent不同,AG-UI语境下的Agent本质上是一个供前端应用调用的远程服务。

1. AG-UI语境下的Agent

AG-UI协议下的Agent是一个具有如下能力和特性的后端服务:

  • 管理对话状态和消息历史记录;
  • 处理传入消息和上下文;
  • 通过事件驱动的流式接口生成响应;
  • 遵循标准化的通信协议。

AG-UI协议中的Agent提供了一系列丰富的功能,可以实现复杂的AI交互。要实现一个完整的Agent,需要提供这样的功能,具体的功能包括:

1.1 交互式通信(Interactive Communication)

Agent通过事件流与前端应用程序建立双向通信通道。这实现了:

  • 逐字符实时流式响应;
  • 用户与人工智能之间的即时反馈循环;
  • 长时间运行操作的进度指示器;
  • 双向结构化数据交换。

1.2 工具使用(Tool Usage)

Agent可以使用工具来执行操作并访问外部资源。重要的是,工具由前端应用程序定义并传递给Agent,从而实现了灵活且可扩展的系统。工具的调用通过一系列事件进行:

  • ToolCallStartEvent:表示工具调用的开始;
  • ToolCallArgsEvent:传递工具调用的参数;
  • ToolCallEndEvent:表示工具调用的完成。

前端应用程序随后可以执行该工具并将结果反馈给Agent。这种双向流程支持复杂的人机协作工作流程:

  • Agent可以请求执行特定操作;
  • 人类可以做出适当的判断来执行这些操作;
  • 结果反馈给Agent以供其继续推理;
  • Agent始终了解流程中做出的所有决策。

1.3 状态管理(State Management)

Agent维护一个在交互过程中保持不变的结构化状态。该状态可以:

  • 通过StateDeltaEvent事件进行增量更新;
  • 通过StateSnapshotEvent事件进行完全刷新;
  • 可供Agent和前端访问;
  • 用于存储用户偏好、对话上下文或应用程序状态。

1.4 多Agent协作(Multi-Agent Collaboration)

AG-UI支持Agent之间的交接和协作:

  • Agent可以将任务委派给其他专业Agent;
  • 多个Agent可以在协调的工作流程中协同工作;
  • Agent之间可以传递状态和上下文;
  • 前端在Agent切换过程中保持一致的用户体验。

比如当需要编程帮助时,一般助理Agent可能会将任务转交给专门的编程Agent,并将对话内容和具体要求传递给对方。

1.5 人机交互工作流(Human-in-the-Loop Workflow)

Agent支持人类干预和协助:

  • Agent可以就特定决策请求人类输入;
  • 前端可以暂停Agent的执行,并在收到人类反馈后恢复执行;
  • 人类可以在Agent的输出最终确定之前对其进行审查和修改。

混合工作流程将人工智能的效率与人类的判断相结合。这使得Agent能够作为协作伙伴而不是自主系统发挥作用。

1.6 对话记忆(Conversational Memory)

Agent会维护完整的对话消息历史记录:

  • 过去的互动会为未来的回复提供信息;
  • 消息历史记录在客户端和服务器之间同步;
  • 消息可以包含丰富的内容(文本、结构化数据、引用);
  • 上下文窗口可以进行管理,以便专注于相关信息。

1.7 跟踪观测(Instrumentation)

Agent可以发出有关其内部流程的元数据:

  • 推理步骤;
  • 性能指标和时间信息;
  • 来源引用和参考文献跟踪;
  • 不同响应选项的置信度分数。

这使得前端能够提供Agent决策过程的透明度,并帮助用户了解结论是如何得出的。

2. 构建一个基于SSE的简易版Agent

构建一个兼容AG-UI协议的Agent具有两种形式。如果我们使用现有的Agent框架,比如MAF和LangChain,我们一般会利用框架提供的扩展(比如中间件)实现与AG-UI的适配。如果我们打算从头开始设计一块Agent框架,则可以在设计之初就将针对AG-UI的支持考虑进去。为了演示针对Agent的开发,以及它与前端应用的通信方式,我们利用ASP.NET Core开发一个极简的Agent后端服务。

通过上面的介绍,我们知道要开发一个完整的Agent需要实现的东西太多,这里作为演示,我们做了如下的简化:

  • 只考虑System、User和Assistant三种角色的消息;
  • 消息的内容只考虑字符串文本这一种形式;
  • 不涉及工具调用;
  • 不涉及基于中断的人机交互

整个实现最终体现在如下两个扩展方法上,核心方法MapAGUIServer会注册一个通过指定模板创建的路由终结点来处理针对Agent的调用。这个路由终结点与指定的IChatClient进行绑定,并利用它调用LLM来生成回答用户的问题。另一个辅助的AddAGUIServer扩展方法旨在注册所需的服务。

public static IServiceCollection AddAGUIServer(this IServiceCollection services);
public static IEndpointConventionBuilder MapAGUIServer(
       this IEndpointRouteBuilder endpoints,
       [StringSyntax("route")] string pattern,
       IChatClient chatClient,
       ChatOptions? chatOptions = null);

如下所示的是整个Agent后端的程序。我们创建了一个OpenAIClient对象,并将其转换成IChatClient对象。我们将这个IChatClient对象作为参数,调用MapAGUIServer扩展方法注册了对应的路由终结点。当前在这之前,调用了AddAGUIServer扩展方法对所需的依赖服务进行了注册。

using DotNetEnv;
using Microsoft.Extensions.AI;
using OpenAI;
using System.ClientModel;

Env.Load();
var endpoint = Environment.GetEnvironmentVariable("OPENAI_BASE_URL")!;
var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY")!;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAGUIServer();
var app = builder.Build();

var chatClient = new OpenAIClient(
        new ApiKeyCredential(apiKey), 
        new OpenAIClientOptions { Endpoint = new Uri(endpoint) })
    .GetChatClient("gpt-5.4-mini")
    .AsIChatClient();
app.MapAGUIServer("/", chatClient);

await app.RunAsync("http://localhost:5566");

接下来我们就来看看最为核心的MapAGUIServer方法是如何实现的。先来看看我们定义的如下几个辅助方法:

public static class Utilities
{
    private static readonly Dictionary<ChatRole, string> RoleMap = new()
    {
        [ChatRole.User] = AGUIRoles.User,
        [ChatRole.Assistant] = AGUIRoles.Assistant,
        [ChatRole.System] = AGUIRoles.System,
        [ChatRole.Tool] = AGUIRoles.Tool,
    };
    public static ChatMessage AsChatMessage(this AGUIMessage message)
    {
        var chatMessage = message switch
        {
            AGUISystemMessage systemMessage => new ChatMessage(ChatRole.System, systemMessage.Content),
            AGUIAssistantMessage assistantMessage => new ChatMessage(ChatRole.Assistant, assistantMessage.Content),
            AGUIUserMessage userMessage => new ChatMessage(ChatRole.User, [.. userMessage.Content.OfType<AGUITextInputContent>().Select(it => new TextContent(it.Text))]),
            _ => throw new ArgumentException("AGUIMessage type is not supported.")
        };
        chatMessage.MessageId = message.Id ?? NewMessageId();
        return chatMessage;
    }
    public static string AsAGUIRole(this ChatRole role)
        => RoleMap.TryGetValue(role, out var aguiRole) ? aguiRole : role.Value.ToLower();

    public static string NewMessageId() => $"msg-{Guid.NewGuid()}";

    public static Task WriteEventsAsync(this HttpResponse response,
        IAsyncEnumerable<BaseEvent> events,
        CancellationToken cancellationToken)
    {
        return SseFormatter.WriteAsync(
            WrapAsSseItems(events, cancellationToken),
            response.Body,
            SerializeEvent,
            cancellationToken);
    }

    private static async IAsyncEnumerable<SseItem<BaseEvent>> WrapAsSseItems(
        IAsyncEnumerable<BaseEvent> events,
        [EnumeratorCancellation] CancellationToken cancellationToken)
    {
        await foreach (var evt in events.WithCancellation(cancellationToken).ConfigureAwait(false))
        {
            yield return new SseItem<BaseEvent>(evt);
        }
    }

    private static void SerializeEvent(SseItem<BaseEvent> item, IBufferWriter<byte> writer)
    {
        using var jsonWriter = new Utf8JsonWriter(writer);
        JsonSerializer.Serialize(jsonWriter, item.Data, AGUIJsonSerializerContext.Default.BaseEvent);
    }
}

四个辅助方法说明如下:

  • AsChatMessage:将AGUIMessage类型的消息转换成ChatMessage。正如前面介绍的假设,我们只考虑system、assistant和user三种角色的消息,并且只考虑文本内容;
  • AsAGUIRole:将ChatRole类型表示的消息角色转换成AG-UI标准的角色;
  • NewMessageId:生成一个新的随机字符串作为消息的ID;
  • WriteEventsAsync:将通过IAsyncEnumerable对象表示的一组异步生成的BaseEvent对象以SSE的方式写入响应。

用于注册路由的核心方法MapAGUIServer定义如下。为了提供Session的支持,我们将基于ThreadId的对话历史保存静态字段_chatHistories表示的字典中。

public static class AGUIEndpointBuilderExtensions
{
    private static readonly ConcurrentDictionary<string, List<ChatMessage>> _chatHistories = [];
    public static IEndpointConventionBuilder MapAGUIServer(
       this IEndpointRouteBuilder endpoints,
       [StringSyntax("route")] string pattern,
       IChatClient chatClient,
       ChatOptions? chatOptions = null)
    {
        return endpoints.MapPost(pattern, async (
            [FromBody] RunAgentInput input, 
            HttpResponse response,
            [FromServices] IOptions<JsonOptions> jsonOptions,
            CancellationToken cancellationToken) =>
        {
            var incomingMessages = input.Messages.Select(it => it.AsChatMessage()).ToArray();            
            var history = _chatHistories.TryGetValue(input.ThreadId, out var list)? list : _chatHistories[input.ThreadId] = [];
            var isClientSession = history.Select(it=>it.MessageId).Intersect(incomingMessages.Select(it => it.MessageId)).Any();
            if (!isClientSession)
            { 
                history.AddRange(incomingMessages);
            }
            var updates = chatClient.GetStreamingResponseAsync(isClientSession? incomingMessages: history, chatOptions);
            var events = updates.AsBaseEvents(input, jsonOptions.Value.JsonSerializerOptions, isClientSession? null:history);
            await response.WriteEventsAsync(events, cancellationToken);
        });
    }

    private static async IAsyncEnumerable<BaseEvent> AsBaseEvents(
        this IAsyncEnumerable<ChatResponseUpdate> updates,
        RunAgentInput input, 
        JsonSerializerOptions jsonSerializerOptions,
        List<ChatMessage>? history = null)
    {
        List<AgentResponseUpdate>? responseUpdates = history is null ? null : [];
        yield return new RunStartedEvent
        {
            ParentRunId = input.ParentRunId,
            RunId = input.RunId,
            ThreadId = input.ThreadId
        };

        string? aguiMessageId = null;
        await foreach (var update in updates)
        {
            responseUpdates?.Add(new AgentResponseUpdate(update));
            if (string.IsNullOrEmpty( update.Text))
            {
                continue;
            }
            var messageId = update.MessageId ?? Utilities.NewMessageId();
            if (aguiMessageId != messageId)
            {
                if (aguiMessageId is not null)
                {
                    yield return new TextMessageEndEvent
                    {
                        MessageId = aguiMessageId,
                    };
                }

                yield return new TextMessageStartEvent
                {
                    MessageId = messageId,
                    Role = update.Role!.Value.AsAGUIRole(),
                    Name = update.AuthorName,
                };

                aguiMessageId = messageId;
            }

            yield return new TextMessageContentEvent
            {
                Delta = update.Text,
                MessageId = aguiMessageId,
            };
        }

        yield return new TextMessageEndEvent
        {
            MessageId = aguiMessageId!,
        };

        yield return new RunFinishedEvent
        {
            RunId = input.RunId,
            ThreadId = input.ThreadId,
            Outcome = new RunFinishedSuccessOutcome()
        };

        if (history is not null)
        {
            var messages = responseUpdates!.ToAgentResponse().Messages;
            history.AddRange(messages);
        }
    }
}

注册的路由处理器采用这样处理来自客户端的调用:

  • 根据输入提供的ThreadId提取存储的对话历史;
  • 提取输入携带的消息列表,并将它们转换成ChatMessage添加到对话历史中;
  • 判断输入的消息列表和对话历史是否有交集:
    • 如果有:意味着采用了客户端Session,输入的就是整个对话历史,此时无需在本地维护Session;
    • 否则:将输入消息添加到对话历史。即使使用了客户端Session,初次调用的消息列表也不得冗余存储,此为无奈之举。
  • 将对话历史作为输入调用IChatClientGetStreamingResponseAsync,并调用AsBaseEvents方法将异步迭代器IAsyncEnumerable<BaseEvent>对象转换成IAsyncEnumerable<BaseEvent>,其中涉及针对如下五种事件的生成:
    • RunStartedEvent
    • TextMessageStartEvent
    • TextMessageContentEvent
    • TextMessageEndEvent
    • RunFinishedEvent
  • 调用WriteEventsAsync方法以异步形式将生成的事件按照SSE格式写入响应输出流;
  • 如果没有采用客户端Session,响应的消息列表会存储对话历史。

另一个针对IServiceCollection的扩展方法AddAGUIServer仅仅按照如下的方式根据AG-UI默认的序列化行为注册了一个IConfigureOptions<JsonOptions>类型的服务。

public static class AGUIServiceExtensions
{
    public static IServiceCollection AddAGUIServer(this IServiceCollection services)
    {
        services.TryAddEnumerable(ServiceDescriptor.Transient<IConfigureOptions<JsonOptions>, ConfigureAGUIJsonOptions>());
        return services;
    }

    internal sealed class ConfigureAGUIJsonOptions : IConfigureOptions<JsonOptions>
    {
        public void Configure(JsonOptions options)
        {
            var chain = options.SerializerOptions.TypeInfoResolverChain;
            chain.Add(AgentAbstractionsJsonUtilities.DefaultOptions.TypeInfoResolver!);
            chain.Add(AGUIJsonSerializerContext.Default.Options.TypeInfoResolver!);
        }
    }
}

3. 使用AG-UI客户端测试

既然上面的Agent后端所是针对AG-UI构建的,那么原则上任何支持AG-UI的协议的客户端都能与之交互。在AG-UI详解-01:创建一个类似ChatGPT应用与Agent实时交互中我曾经使用过AGUIChatClient来调用后端基于AIAgent构建的AG-UI后端服务,我们看看是否可以利用它直接与我们构建的AG-UI Agent交互。

如下面的演示程序所示。和AG-UI详解-01中的演示程序相比较,我只改动了两点,一个是将AGUIChatClient连接的后端地址改成上面构建的ASP.NET Core应用的监听地址。再就是提供以首个消息的方式指定了一个系统指令(之前这个指令直接在后端指定)。

using Microsoft.Agents.AI;
using Microsoft.Agents.AI.AGUI;
using Microsoft.Extensions.AI;

using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) };
var chatClient = new AGUIChatClient(httpClient, "http://localhost:5566");

var agent = chatClient.AsAIAgent(
    name: "AGUIAgent",
    description: "AG-UI Client Agent");
var session = await agent.CreateSessionAsync();
var instructions = new ChatMessage(ChatRole.System, """
    你是一个深谙中国古代历史的专家,善于根据正史,以公正客观的态度于人交流历史问题。
    对于用户提出的问题,请以简介概括性的语言予以答复,字数尽量保持在200字以内。
    """);
List<ChatMessage> messages = [instructions];
while (true)
{
    Console.Write("\n$ (:q or quit to exit): ");
    var message = Console.ReadLine();
    if (message is ":q" or "quit")
    {
        break;
    }

    messages.Add(new ChatMessage(ChatRole.User, message));
    await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(messages, session))
    {
        ChatResponseUpdate chatUpdate = update.AsChatResponseUpdate();
        foreach (AIContent content in update.Contents)
        {
            if (content is TextContent textContent)
            {
                Console.Write(textContent.Text);
            }
            else if (content is ErrorContent errorContent)
            {
                Console.WriteLine($"\n[Error: {errorContent.Message}]");
            }
        }
    }
    messages.Clear();
}

然后我们利用上述程序构建的对话循环,简单讨论了如下这个关于战国纵横家排名的问题,可以看出三轮对话还是很流畅的。由于整个流程都是采用流式处理,所以响应内容是逐字输出的,不会有明显的卡顿。

$ (:q or quit to exit): 列举战国最重要的四个纵横家
战国时期最重要、影响最大的纵横家,通常可列为以下四人:

- 苏秦:主张“合纵”,游说六国联合抗秦,曾佩六国相印,是合纵战略代表人物。
- 张仪:秦国重要谋士,主张“连横”,以外交分化六国,帮助秦国逐步取得优势。
- 公孙衍:早期纵横家代表,曾任魏、秦将相,积极推动合纵抗秦,对战国外交格局影响很大。
- 范雎:虽偏重政治谋略,但以远交近攻策略著称,辅佐秦昭襄王,加强秦国统一基础。

其中苏秦、张仪最具代表性,被后世并称“纵横家双璧”。

$ (:q or quit to exit): 我个人觉得应该有鲁仲连
你的看法有一定道理。若从“纵横家的典型性”和历史影响看,鲁仲连确实常被列入战国顶级纵横家之列,甚至有人会用他替代范雎或公孙衍。

鲁仲连是齐国人,以游说诸侯、反对强秦著称。他最著名的是赵国邯郸之围时,成功说服魏、楚援赵,并以“不帝秦”闻名,体现了强烈的政治理想色彩。与苏秦、张仪偏重权术外交不同,鲁仲连更有士人风骨,因此后世评价很高。

若按“纵横家代表性”而言,常见组合其实是:
苏秦、张仪、公孙衍、鲁仲连。

而范雎更多被视为秦国政治战略家。】

$ (:q or quit to exit): ‌甘茂‌‌‌‌和范雎不相伯仲吧
确实,甘茂的地位并不低,若论战国中后期秦国的重要谋臣,他与范雎可以说各有千秋。

甘茂主要活跃于秦武王时期,长于军事与外交结合,最著名的是提出并推动“宜阳之战”,帮助秦国打通东进通道。他也善于权变游说,《战国策》中有不少相关记载,带有明显纵横家色彩。

范雎则更偏战略层面。他提出“远交近攻”,并推动废除“四贵”干政,加强王权,对秦国长期统一战略影响更深。因此后世政治史中的地位通常高于甘茂。

如果按“纵横家风格”来说,甘茂确实比范雎更接近传统意义上的纵横之士。战国人物本来就很难绝对排名,不同标准会得出不同名单。
内容概要:本文档聚焦于“含分布式电源的配电网可靠性评估研究”,提供了完整的Matlab代码实现方案。研究系统地构建了分布式电源接入背景下的配电网可靠性分析模型,涵盖系统故障建模、可靠性指标(如SAIDI、SAIFI、ASAI等)计算方法,并通过Matlab编程实现了高效的仿真评估算法。文档不仅阐述了核心理论与数学模型,还展示了具体的程序架构与关键函数设计,帮助读者深入理解配电网在新能源接入场景下的运行风险与薄弱环节。此外,文档附带大量相关电力系统前沿研究主题,如微电网优化、储能配置、电动汽车调度、构网型变流器控制等,凸显其在现代智能电网研究中的重要地位,并提供网盘链接以获取完整代码与仿真模型,便于科研复现与工程应用。; 适合人群:具备电力系统基础理论知识和Matlab编程能力的高校研究生、科研人员及电力行业工程技术从业者,特别适用于从事配电网规划、分布式能源并网、电力系统可靠性分析及相关领域研究的专业人士。; 使用场景及目标:①开展含分布式电源的配电网可靠性建模与仿真分析;②完成学术论文复现、学位论文课题设计或实际工程项目的风险评估;③掌握利用Matlab/Simulink进行电力系统可靠性定量评估的核心方法,提升科研创新能力与工程实践水平。; 阅读建议:建议结合文中提供的网盘资源,下载完整的代码与模型进行动手实践,优先剖析核心算法逻辑与仿真流程,理解不同分布式电源接入位置与容量对系统可靠性的影响规律,再进一步拓展至其他类似课题研究,同时注意参考文献引用与复现细节,确保研究成果的科学性与严谨性。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值