[AG-UI详解-05]如何构建一个兼容AG-UI协议的客户端

AG-UI详解-04:如果构建一个兼容AG-UI协议的Agent中,我们通过在一个ASP.NET Core应用中注册了一个绑定指定IChatClient的路由终结点,使外界可以通过Web调用的方式以AG-UI协议调用对应的LLM。在这篇文章中,我们看看AG-UI客户端又该如何开发。和前面一样,我们的目的仅仅是作一个简单的演示,并非开发一款真正的功能完备的AG-UI客户端,所以我们的客户端建立在一个简单的假设上面,比如不考虑工具调用、人机交互、多模态消息内容和中断等。

1. 流式读取AG-UI事件

我们创建的客户端将用于对接AG-UI详解-04:如果构建一个兼容AG-UI协议的Agent创建的Agent后端。由于此后端采用SSE输出AG-UI响应的事件,所以我们的客户端的核心功能就是读取这些事件并交给上层应用处理。

我们创建的AG-UI客户端的实现非常简单。如下面这个AGUIClient所示,唯一的InvokeAsync方法将RunAgentInput对象作为输入。我们将RunAgentInput对象序列化成JSON,作为远程调用AG-UI服务(地址通过构造函数指定)的输入。在得到作为响应的HttpResponseMessage对象后,我们利用创建的SseParser采用SSE协议将每个Chunk读出来,并反序列化成BaseEvent对象。整个方法以IAsyncEnumerable<BaseEvent>的形式返回读取的AG-UI事件。

public class AGUIClient(string server) : IDisposable
{
    private readonly HttpClient _httpClient = new ();
    public async IAsyncEnumerable<BaseEvent> InvokeAsync(RunAgentInput input,[EnumeratorCancellation]CancellationToken cancellationToken)
    {
        var response = await _httpClient.PostAsJsonAsync(server, input, cancellationToken);
        using Stream stream = await response.Content.ReadAsStreamAsync(cancellationToken);
        var sseParser = SseParser.Create(stream, (eventType, bytes) => JsonSerializer.Deserialize<BaseEvent>(bytes)!);
        await foreach (SseItem<BaseEvent> item in sseParser.EnumerateAsync(cancellationToken))
        {
            yield return item.Data;
        }
    }
    public void Dispose()=> _httpClient.Dispose();
}

2. 利用AGUIClient构建一个聊天应用

我们在一个控制台上利用上面定义的这个AGUIClient构建一个简单的聊天应用。如下面代码所示,我们通过连接AG-UI详解-04:如果构建一个兼容AG-UI协议的Agent构建的Agent后端创建出AGUIClient对象后,开启了一个对话循环。为了保持对话上下文,我们让输入的RunAgentInput对象共享同一个ThreadId

var threadId = $"thread-{Guid.NewGuid()}";
var runIndex = 0;
var input = new RunAgentInput
{
    ThreadId = threadId,
};
var isFirstDialog = true;
var systemMessage = new AGUISystemMessage { Content = """
    你是一个深谙中国古代历史的专家,善于根据正史,以公正客观的态度于人交流历史问题。
    对于用户提出的问题,请以简介概括性的语言予以答复,字数尽量保持在200字以内。
    """ };
using var client = new AGUIClient("http://localhost:5566");
while (true)
{
    Console.Write("\n$ (:q or quit to exit): ");
    var message = Console.ReadLine();
    if (message is ":q" or "quit")
    {
        break;
    }
    input.RunId = $"run-{runIndex++}";
    input.Messages = isFirstDialog
        ? [systemMessage, new AGUIUserMessage { Content = new AGUIUserContent(message!) }]
        : [new AGUIUserMessage { Content = new AGUIUserContent(message!) }];
    isFirstDialog = false;

    Console.WriteLine();
    await foreach (var @event in client.InvokeAsync(input, CancellationToken.None))
    {
        if (@event is TextMessageContentEvent contentEvent && !string.IsNullOrEmpty(contentEvent.Delta))
        {
            Console.Write(contentEvent.Delta);
        }
    }
}

我们将用户输入转换成一个AGUIUserMessage对象,并添加到RunAgentInputMessages列表中。对于初始调用,我们还会添加一个AGUISystemMessage对象作为系统指令。我们将RunAgentInput对象作为输入调用AGUIClientInvokeAsync方法,并得到一个IAsyncEnumerable<BaseEvent>对象。在异步迭代中,我们输出TextMessageContentEvent事件携带的实时生成的文本。运行程序后,我们于AI展开如下这段关于战国国君排名的简单对话。

$ (:q or quit to exit): 给战国国君排个序,你觉得前四位会有谁?
若以“综合国力塑造、制度影响、战略眼光、历史结果”衡量,我会把战国国君前四大致排为:

1. 秦孝公:重用商鞅变法,彻底改变秦国结构,为统一奠基。
2. 齐威王:整顿吏治、广纳谏言,齐国一度成为东方最强国。
3. 赵武灵王:推行“胡服骑射”,完成军事革命,使赵国跃升强国。
4. 秦昭襄王:在位最长,重用范雎、白起,持续削弱六国,统一已见雏形。

若看个人雄才,燕昭王、魏文侯、楚悼王也很突出;若看最终成果,秦始皇当然最耀眼,但严格说已属“战国末—秦朝开端”。

$ (:q or quit to exit): 对于这四位,赵雍的结局令人唏嘘,你觉得是什么造成的?

赵武灵王赵雍的悲剧,核心还是“改革成功,却未处理好权力交接”。

他通过胡服骑射让赵国崛起,但晚年过早让位给幼子赵何,自称“主父”,形成“两套权威并存”。同时,他偏爱长子赵章,又未彻底明确继承秩序,结果引发“沙丘之乱”。赵章失败后,赵成、李兑等人担心主父再起变数,最终将赵雍围困沙丘宫,活活饿死。

赵雍强于开创,却失于收束。他敢打破传统,却低估了宗室、贵族与继承问题的破坏力。战国很多名君都能“兴国”,但“善终”往往比改革更难。赵武灵王正是典型例子。

$ (:q or quit to exit): 如果他能向其余三位一样处理好继承人的问题,地位能否排在田婴齐前面?

有可能,甚至不少后世史家会倾向把赵武灵王排到齐威王之前。

赵武灵王的“胡服骑射”不仅是强兵,更是文明层面的制度突破。他直接改变了中原传统战争方式,使赵国从二流国家跃升为能与秦抗衡的强国,对战国军事格局影响极大。相比之下,齐威王的成就更多是“中兴齐国”,制度创新和长期影响力略逊一筹。

但赵雍的问题在于:他留下的是“强国”,却不是“稳固秩序”。他死后赵国内耗加剧,国力虽强,却逐渐失去持续扩张能力。而齐威王给齐国留下的政治结构相对稳定,还开启了稷下学宫的文化高峰。

所以若赵武灵王能平稳完成权力交接,让改革成果延续两三代,他的历史地位确实有机会进入“战国第一流君主”的更前列。

3. 将AGUIClient进一步封装成IChatClient

如果将基于AG-UI的Agent后端视为一个AI服务(实际上就是),我们可以定义一个连接它的IChatClient实现类型,以方便上层应用的使用,毕竟AG-UI的BaseEvent还偏底层。为此我们定义了如下这个AGUIChatClient类型。

如代码片段所示,AGUIChatClient实际上是对上面定义的AGUIClient对象的封装。由于作为AG-UI输入的RunAgentInput需要指定ThreadId,对于IChatClient接口的定义来看,这个ThreadId要么放在传入的消息中,要么放在ChatOptions中,我选择了后者。在实现的GetStreamingResponseAsync中,我们从ChatOptions中取出ThreadId,并将传入的ChatMessage消息列表转换成AGUIMessage消息列表,两者组合生成的RunAgentInput作为调用AGUIClient对象InvokeAsync方法的输入。

public class AGUIChatClient(string server) : IChatClient
{
    private readonly AGUIClient _client = new (server);
    private readonly JsonSerializerOptions _jsonSerializerOptions = AGUIJsonSerializerContext.Default.Options;
    public void Dispose()=>_client.Dispose();
    public async Task<ChatResponse> GetResponseAsync(
        IEnumerable<ChatMessage> messages, 
        ChatOptions? options = null, 
        CancellationToken cancellationToken = default)
    {
        List<AgentResponseUpdate> updates = [];
        await foreach (var update in GetStreamingResponseAsync(messages, options, cancellationToken))
        {
            updates.Add(new AgentResponseUpdate(update));
        }
        return new ChatResponse(updates.ToAgentResponse().Messages);
    }

    public object? GetService(Type serviceType, object? serviceKey = null)=> null;

    public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
        IEnumerable<ChatMessage> messages, 
        ChatOptions? options = null, 
        CancellationToken cancellationToken = default)
    {
        var threadId = options?.AdditionalProperties?.TryGetValue<string>("agui-thread-id", out var value) == true 
            ? value : $"thread-{Guid.NewGuid()}";
        threadId ??= $"thread-{Guid.NewGuid()}";
        var runId = $"run-{Guid.NewGuid()}";
        var input = new RunAgentInput
        {
            ThreadId = threadId,
            RunId = runId,
            Messages = messages.Select(AsAGUIMessages).ToList(),
        };

        return _client.InvokeAsync(input, cancellationToken)
            .AsChatResponseUpdatesAsync(_jsonSerializerOptions, cancellationToken)
    }

    private AGUIMessage AsAGUIMessages(ChatMessage message)
    {
        var content = string.Join("\n\n", message.Contents.OfType<TextContent>().Select(it => it.Text));
        var role = message.Role;
        return role == ChatRole.System
            ? new AGUISystemMessage { Content = content }
            : role == ChatRole.User
            ? new AGUIUserMessage { Content = new AGUIUserContent(content) }
            : role == ChatRole.Assistant
            ? new AGUIAssistantMessage { Content = content }
            : throw new ArgumentException($"ChatMessageType {message.GetType()} is not supported.");
    }
}

我们调用如下定义的扩展方法AsChatResponseUpdatesAsyncAGUIClient对象InvokeAsync方法返回的IAsyncEnumerable<BaseEvent>转换成最终返回的IAsyncEnumerable<ChatResponseUpdate>对象。按照我们之前的假设,我们只考虑RunStartedEventRunFinishedEventRunErrorEventTextMessageStartEventTextMessageEndEvent这五种AG-UI事件。

internal static class ChatResponseUpdateAGUIExtensions
{
    private static readonly MediaTypeHeaderValue? s_jsonPatchMediaType = new("application/json-patch+json");
    private static readonly MediaTypeHeaderValue? s_json = new("application/json");

    public static async IAsyncEnumerable<ChatResponseUpdate> AsChatResponseUpdatesAsync(
        this IAsyncEnumerable<BaseEvent> events,
        JsonSerializerOptions jsonSerializerOptions,
        [EnumeratorCancellation] CancellationToken cancellationToken = default)
    {
        string? conversationId = null;
        string? responseId = null;
        var textMessageBuilder = new TextMessageBuilder();
        await foreach (var evt in events.WithCancellation(cancellationToken).ConfigureAwait(false))
        {
            switch (evt)
            {
                case RunStartedEvent runStarted:
                    conversationId = runStarted.ThreadId;
                    responseId = runStarted.RunId;
                    textMessageBuilder.SetConversationAndResponseIds(conversationId, responseId);
                    yield return new ChatResponseUpdate(ChatRole.Assistant,[])
                    {
                        ConversationId = runStarted.ThreadId,
                        ResponseId = runStarted.RunId,
                        CreatedAt = DateTimeOffset.UtcNow
                    }; ;
                    break;
                case RunFinishedEvent runFinished:
                    yield return new ChatResponseUpdate(ChatRole.Assistant, runFinished.Result?.GetRawText())
                    {
                        ConversationId = conversationId,
                        ResponseId = responseId,
                        CreatedAt = DateTimeOffset.UtcNow
                    };
                    break;
                case RunErrorEvent runError:
                    yield return new ChatResponseUpdate(ChatRole.Assistant, [(new ErrorContent(runError.Message) { ErrorCode = runError.Code })]);
                    break;

                case TextMessageStartEvent textStart:
                    textMessageBuilder.AddTextStart(textStart);
                    break;
                case TextMessageContentEvent textContent:
                    yield return textMessageBuilder.EmitTextUpdate(textContent);
                    break;
                case TextMessageEndEvent textEnd:
                    textMessageBuilder.EndCurrentMessage(textEnd);
                    break;
            }
        }
    } 

    private sealed class TextMessageBuilder()
    {
        private ChatRole _currentRole;
        private string? _currentMessageId;
        private string? _conversationId;
        private string? _responseId;

        public void SetConversationAndResponseIds(string? conversationId, string? responseId)
        {
            _conversationId = conversationId;
            _responseId = responseId;
        }

        public void AddTextStart(TextMessageStartEvent textStart)
        {
            if (_currentRole != default || this._currentMessageId != null)
            {
                throw new InvalidOperationException("Received TextMessageStartEvent while another message is being processed.");
            }

            _currentRole = AGUIChatMessageExtensions.MapChatRole(textStart.Role);
            _currentMessageId = textStart.MessageId;
        }

        internal ChatResponseUpdate EmitTextUpdate(TextMessageContentEvent textContent)
        {
            return new ChatResponseUpdate(
                _currentRole,
                textContent.Delta)
            {
                ConversationId = this._conversationId,
                ResponseId = this._responseId,
                MessageId = textContent.MessageId,
                CreatedAt = DateTimeOffset.UtcNow
            };
        }

        internal void EndCurrentMessage(TextMessageEndEvent textEnd)
        {
            _currentRole = default;
            _currentMessageId = null;
        }
    }
}

4. 利用AGUIChatClient构建聊天应用

我们将前面聊天应用使用的AGUIClient替换成AGUIChatClient,这样我们可以直接使用ChatMessage进行交流了。

var threadId = $"thread-{Guid.NewGuid()}";
var options = new ChatOptions { AdditionalProperties = new AdditionalPropertiesDictionary([new KeyValuePair<string, object?>("agui-thread-id", threadId)]) };

var isFirstDialog = true;
var systemMessage = new ChatMessage (ChatRole.System, """
    你是一个深谙中国古代历史的专家,善于根据正史,以公正客观的态度于人交流历史问题。
    对于用户提出的问题,请以简介概括性的语言予以答复,字数尽量保持在200字以内。
    """ );
using var chatClient = new AGUIChatClient("http://localhost:5566");
while (true)
{
    Console.Write("\n$ (:q or quit to exit): ");
    var message = Console.ReadLine();
    if (message is ":q" or "quit")
    {
        break;
    }
    var userMessage = new ChatMessage(ChatRole.User, message);
    List<ChatMessage> messages = isFirstDialog
        ? [systemMessage, userMessage]
        : [userMessage];
    isFirstDialog = false;
    Console.WriteLine();
    await foreach (var update in chatClient.GetStreamingResponseAsync(messages, options))
    {
        foreach (AIContent content in update.Contents)
        {
            if (content is TextContent textContent)
            {
                Console.Write(textContent.Text);
            }
        }
    }
}

这是我们运行程序后进行的一段对话。

$ (:q or quit to exit): 可否认为周公旦是传统中国文化的奠基人之一

可以这样认为,但需加限定。周公旦并非“传统中国文化”的唯一奠基人,却是早期核心塑造者之一。西周初年,他制礼作乐、确立宗法与封建秩序,并以“敬天保民”强化政治伦理,对后世儒家所重视的礼制、德治、名分影响极深。孔子尤其推崇周公,称“郁郁乎文哉,吾从周”,使周公在后世被视为礼乐文明的代表人物。不过,中国传统文化的形成还经历了春秋战国诸子、秦汉制度化等长期发展,因此更准确的说法是:周公旦是中华礼乐政治与儒家传统的重要奠基者之一。

$ (:q or quit to exit): 有没有可能因为周文化的强势,导致商王朝,尤其是帝辛这段历史包括他本人被错误评价?

有这种可能,而且学界普遍承认“胜者书写历史”对商亡周兴的叙述影响很大。现存关于帝辛(商纣王)的主要记载,多出自周人及后世儒家文献,带有“以暴君证明革命合理性”的政治目的,因此诸如“酒池肉林”等故事,很可能存在夸张和道德化加工。

但这不等于帝辛一定是“明君”。甲骨文与考古材料显示,商后期长期战争频繁、赋役沉重,政治压力确实存在。周人能够联合诸侯与部分商人反商,也说明商王朝内部并非毫无矛盾。

因此较稳妥的看法是:帝辛形象大概率被妖魔化了,但商末统治出现严重问题也并非完全虚构。

$ (:q or quit to exit): 但是如何理解“泰誓”记载的伐纣的理由无非是:今殷王纣乃用其妇人之言,自绝于天,毁坏其三正,离其王父母弟,乃断弃其先祖之乐,乃为淫声,用变乱正声,怡说妇人。作为战斗檄文,列举的这些罪行都仅限于此,后世史书记载的那些是否是欲加之罪呢?

这是研究商周之际时常被讨论的问题。《尚书·泰誓》确实很值得注意:其中对帝辛的指责,主要集中于“乱礼”“弃祖制”“亲妇人”“坏正声”等,并未出现后世最著名的炮烙、酒池肉林等极端暴政叙述。

这说明两点:第一,西周初年的政治宣传,核心是强调纣“失德失礼”,因为周人的合法性建立在“天命转移”与礼制正统上;第二,后世关于纣的暴虐形象,很可能是在长期儒家伦理化叙事中不断累积、强化的。

不过也不能据此完全否定商末问题。对古代王朝而言,“弃礼”“疏宗族”“重声色”本身就是严重政治指控,意味着统治集团离心。周人能迅速灭商,也说明商内部确有危机。

因此,更合理的理解是:早期文献中的纣,主要是“失德之君”;而后世则逐渐将其塑造成极端暴君,两者之间存在明显的历史层累。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值