using System.ClientModel;
using Dpz.Core.Entity.Base;
using Dpz.Core.Service.Network.Models;
using ModelContextProtocol.Client;
using OpenAI;
using OpenAI.Chat;
using OpenAI.Models;
using ChatMessage = OpenAI.Chat.ChatMessage;

namespace Dpz.Core.Service.Network;

/// <summary>
/// 基于 OpenAI SDK 的聊天发送和模型查询实现
/// </summary>
public class OpenAiService : IOpenAiService
{
    private const string ConfigurationSectionName = "OpenAI";

    private readonly OpenAiChatOptions _defaults;
    private readonly ChatClient _chatClient;
    private readonly OpenAIModelClient _modelClient;
    private readonly ILogger<OpenAiService> _logger;

    /// <summary>
    /// 初始化 <see cref="OpenAiService"/> 类的新实例
    /// </summary>
    /// <param name="configuration">包含所需 OpenAI 配置节的应用配置</param>
    /// <param name="logger">用于请求诊断的日志记录器</param>
    public OpenAiService(IConfiguration configuration, ILogger<OpenAiService> logger)
    {
        _logger = logger;
        _defaults = LoadOptions(configuration);
        _chatClient = CreateChatClient(_defaults);
        _modelClient = CreateModelClient(_defaults);
    }

    /// <inheritdoc />
    public async Task<ResponseResult<OpenAiChatStreamResult>> SendMessageAsync(
        IReadOnlyList<ChatMessage> messages,
        Func<string, CancellationToken, Task>? onDeltaContentAsync = null,
        Action<OpenAiChatRequestOptions>? options = null,
        CancellationToken cancellationToken = default
    )
    {
        var result = new ResponseResult<OpenAiChatStreamResult>();
        if (messages is not { Count: > 0 })
        {
            return result.WithFail("Messages cannot be empty");
        }

        var requestOptions = CreateRequestOptions();
        options?.Invoke(requestOptions);

        var model = string.IsNullOrWhiteSpace(requestOptions.Model)
            ? _defaults.Model
            : requestOptions.Model;
        var completionOptions = CreateCompletionOptions(requestOptions);
        var chatClient = string.Equals(model, _defaults.Model, StringComparison.Ordinal)
            ? _chatClient
            : CreateChatClient(_defaults, model);

        try
        {
            var contentBuilder = new StringBuilder();
            ChatTokenUsage? usage = null;
            var responseModel = model;

            var updates = chatClient.CompleteChatStreamingAsync(
                messages,
                completionOptions,
                cancellationToken
            );

            await foreach (var update in updates)
            {
                cancellationToken.ThrowIfCancellationRequested();

                if (!string.IsNullOrWhiteSpace(update.Model))
                {
                    responseModel = update.Model;
                }

                if (update.Usage != null)
                {
                    usage = update.Usage;
                }

                // SDK 产出已解析的流式更新;此代码块仅负责拼接文本增量,
                // 并交由调用方实时持久化或转发。
                foreach (var contentPart in update.ContentUpdate)
                {
                    if (string.IsNullOrEmpty(contentPart.Text))
                    {
                        continue;
                    }

                    contentBuilder.Append(contentPart.Text);

                    // 将取消令牌传播到用户代码中,以便调用方能快速停止
                    if (onDeltaContentAsync != null)
                    {
                        await onDeltaContentAsync(contentPart.Text, cancellationToken);
                    }
                }
            }

            return result.WithOk(
                new OpenAiChatStreamResult
                {
                    Model = responseModel,
                    Content = contentBuilder.ToString(),
                    PromptTokens = usage?.InputTokenCount,
                    CompletionTokens = usage?.OutputTokenCount,
                    TotalTokens = usage?.TotalTokenCount,
                }
            );
        }
        catch (OperationCanceledException)
        {
            _logger.LogInformation("OpenAI streaming chat request was cancelled");
            throw;
        }
        catch (Exception e)
        {
            _logger.LogError(e, "OpenAI streaming chat request failed");
            return result.WithFail(e.Message);
        }
    }

    /// <inheritdoc />
    public async Task<ResponseResult<IReadOnlyList<OpenAIModel>>> GetModelsAsync(
        CancellationToken cancellationToken = default
    )
    {
        var result = new ResponseResult<IReadOnlyList<OpenAIModel>>();

        try
        {
            var models = await _modelClient.GetModelsAsync(cancellationToken);
            return result.WithOk(models.Value.ToList());
        }
        catch (OperationCanceledException)
        {
            _logger.LogInformation("OpenAI model list request was cancelled");
            throw;
        }
        catch (Exception e)
        {
            _logger.LogError(e, "OpenAI model list request failed");
            return result.WithFail(e.Message);
        }
    }

    /// <inheritdoc />
    public async Task<ResponseResult<OpenAiChatStreamResult>> SendMessageWithMcpAsync(
        IReadOnlyList<ChatMessage> messages,
        McpClient mcpClient,
        Func<string, CancellationToken, Task>? onDeltaContentAsync = null,
        Action<OpenAiChatRequestOptions>? options = null,
        int maxToolRounds = 8,
        Func<string, string, CancellationToken, Task>? onToolCallAsync = null,
        CancellationToken cancellationToken = default
    )
    {
        var result = new ResponseResult<OpenAiChatStreamResult>();
        if (messages is not { Count: > 0 })
        {
            return result.WithFail("Messages cannot be empty");
        }

        ArgumentNullException.ThrowIfNull(mcpClient);

        var mcpTools = await mcpClient.ListToolsAsync(cancellationToken: cancellationToken);
        if (mcpTools.Count == 0)
        {
            return await SendMessageAsync(
                messages,
                onDeltaContentAsync,
                options,
                cancellationToken
            );
        }

        var requestOptions = CreateRequestOptions();
        options?.Invoke(requestOptions);

        var model = string.IsNullOrWhiteSpace(requestOptions.Model)
            ? _defaults.Model
            : requestOptions.Model;
        var completionOptions = CreateCompletionOptions(requestOptions);
        foreach (var mcpTool in mcpTools)
        {
            completionOptions.Tools.Add(OpenAiMcpToolSupport.ToChatTool(mcpTool));
        }

        var chatClient = string.Equals(model, _defaults.Model, StringComparison.Ordinal)
            ? _chatClient
            : CreateChatClient(_defaults, model);
        var sendMessage = new List<ChatMessage>(messages);
        var allowedRounds = OpenAiMcpToolSupport.ClampMaxToolRounds(maxToolRounds);

        try
        {
            var usage = new ChatUsageAccumulator { ResponseModel = model };
            ChatRoundResult? lastRound = null;
            var preferStreaming = true;

            // 有剩余轮次时允许 function call; 最后一轮改 ToolChoice.None, 不清空 Tools
            // 清空工具声明会让部分兼容端把调用写进文本, 协议侧应继续声明工具但禁止再选
            for (var round = 0; round <= allowedRounds; round++)
            {
                var allowTools = round < allowedRounds;
                completionOptions.ToolChoice = allowTools
                    ? ChatToolChoice.CreateAutoChoice()
                    : ChatToolChoice.CreateNoneChoice();

                var roundResult = await CompleteChatRoundAsync(
                    chatClient,
                    sendMessage,
                    completionOptions,
                    usage,
                    onDeltaContentAsync,
                    preferStreaming,
                    cancellationToken
                );
                lastRound = roundResult;
                preferStreaming = roundResult.WasStreamed;

                if (roundResult.ToolCalls.Count == 0 || !allowTools)
                {
                    break;
                }

                sendMessage.Add(new AssistantChatMessage(roundResult.ToolCalls));
                await ExecuteToolCallsAsync(
                    mcpClient,
                    sendMessage,
                    roundResult.ToolCalls,
                    onToolCallAsync,
                    cancellationToken
                );
            }

            if (lastRound is null)
            {
                return result.WithFail("Model returned no completion");
            }

            if (lastRound.ToolCalls.Count > 0)
            {
                _logger.LogWarning(
                    "MCP chat ended with pending tool calls. FinishReason:{FinishReason} Count:{Count}",
                    lastRound.FinishReason,
                    lastRound.ToolCalls.Count
                );
                return result.WithFail("Model requested tool calls after the last round");
            }

            var content = lastRound.Text;
            if (string.IsNullOrWhiteSpace(content))
            {
                return result.WithFail("Model returned empty content");
            }

            return result.WithOk(
                new OpenAiChatStreamResult
                {
                    Model = usage.ResponseModel,
                    Content = content,
                    PromptTokens = usage.PromptTokens > 0 ? usage.PromptTokens : null,
                    CompletionTokens = usage.CompletionTokens > 0 ? usage.CompletionTokens : null,
                    TotalTokens = usage.TotalTokens > 0 ? usage.TotalTokens : null,
                }
            );
        }
        catch (OperationCanceledException)
        {
            _logger.LogInformation("OpenAI MCP chat request was cancelled");
            throw;
        }
        catch (Exception e)
        {
            _logger.LogError(e, "OpenAI MCP chat request failed");
            return result.WithFail(e.Message);
        }
    }

    private async Task ExecuteToolCallsAsync(
        McpClient mcpClient,
        List<ChatMessage> sendMessage,
        IReadOnlyList<ChatToolCall> toolCalls,
        Func<string, string, CancellationToken, Task>? onToolCallAsync,
        CancellationToken cancellationToken
    )
    {
        foreach (var toolCall in toolCalls)
        {
            cancellationToken.ThrowIfCancellationRequested();
            _logger.LogInformation(
                "MCP tool call {ToolName} {ToolCallId}",
                toolCall.FunctionName,
                toolCall.Id
            );

            var argumentsJson = toolCall.FunctionArguments?.ToString() ?? "";
            if (onToolCallAsync != null)
            {
                await onToolCallAsync(toolCall.FunctionName, argumentsJson, cancellationToken);
            }

            var arguments = OpenAiMcpToolSupport.ParseArguments(toolCall.FunctionArguments);
            string toolText;
            try
            {
                var toolResult = await mcpClient.CallToolAsync(
                    toolCall.FunctionName,
                    arguments,
                    cancellationToken: cancellationToken
                );
                toolText = OpenAiMcpToolSupport.FormatToolResult(toolResult);
            }
            catch (Exception ex) when (ex is not OperationCanceledException)
            {
                _logger.LogWarning(
                    ex,
                    "MCP tool call failed {ToolName} {ToolCallId}",
                    toolCall.FunctionName,
                    toolCall.Id
                );
                toolText = OpenAiMcpToolSupport.Truncate("Error: " + ex.Message);
            }

            sendMessage.Add(new ToolChatMessage(toolCall.Id, toolText));
        }
    }

    /// <summary>
    /// 以流式补全执行一轮对话;流式在首个数据帧之前失败时回退到非流式补全
    /// </summary>
    private async Task<ChatRoundResult> CompleteChatRoundAsync(
        ChatClient chatClient,
        IReadOnlyList<ChatMessage> sendMessage,
        ChatCompletionOptions completionOptions,
        ChatUsageAccumulator usage,
        Func<string, CancellationToken, Task>? onDeltaContentAsync,
        bool preferStreaming,
        CancellationToken cancellationToken
    )
    {
        if (preferStreaming)
        {
            var streamed = await TryCompleteChatStreamingRoundAsync(
                chatClient,
                sendMessage,
                completionOptions,
                usage,
                onDeltaContentAsync,
                cancellationToken
            );
            if (streamed != null)
            {
                return streamed;
            }

            _logger.LogWarning(
                "MCP streaming completion not supported by endpoint, fallback to non-streaming"
            );
        }

        var completion = await CompleteChatAndAccumulateUsageAsync(
            chatClient,
            sendMessage,
            completionOptions,
            usage,
            cancellationToken
        );
        var toolCalls = completion.ToolCalls.ToList();
        var text = GetCompletionText(completion);
        if (text.Length > 0 && toolCalls.Count == 0 && onDeltaContentAsync != null)
        {
            await onDeltaContentAsync(text, cancellationToken);
        }

        return new ChatRoundResult
        {
            Text = text,
            ToolCalls = toolCalls,
            FinishReason = completion.FinishReason,
            WasStreamed = false,
        };
    }

    private async Task<ChatRoundResult?> TryCompleteChatStreamingRoundAsync(
        ChatClient chatClient,
        IReadOnlyList<ChatMessage> sendMessage,
        ChatCompletionOptions completionOptions,
        ChatUsageAccumulator usage,
        Func<string, CancellationToken, Task>? onDeltaContentAsync,
        CancellationToken cancellationToken
    )
    {
        var contentBuilder = new StringBuilder();
        var toolCallAccumulators = new Dictionary<int, StreamingToolCallAccumulator>();
        ChatFinishReason? finishReason = null;
        var receivedAnyUpdate = false;

        await using var enumerator = chatClient
            .CompleteChatStreamingAsync(sendMessage, completionOptions, cancellationToken)
            .GetAsyncEnumerator(cancellationToken);

        try
        {
            while (await enumerator.MoveNextAsync())
            {
                receivedAnyUpdate = true;
                var update = enumerator.Current;
                cancellationToken.ThrowIfCancellationRequested();

                if (!string.IsNullOrWhiteSpace(update.Model))
                {
                    usage.ResponseModel = update.Model;
                }

                if (update.Usage != null)
                {
                    usage.PromptTokens += update.Usage.InputTokenCount;
                    usage.CompletionTokens += update.Usage.OutputTokenCount;
                    usage.TotalTokens += update.Usage.TotalTokenCount;
                }

                if (update.FinishReason != null)
                {
                    finishReason = update.FinishReason;
                }

                foreach (var contentPart in update.ContentUpdate)
                {
                    if (string.IsNullOrEmpty(contentPart.Text))
                    {
                        continue;
                    }

                    contentBuilder.Append(contentPart.Text);

                    // 将取消令牌传播到用户代码中,以便调用方能快速停止
                    if (onDeltaContentAsync != null)
                    {
                        await onDeltaContentAsync(contentPart.Text, cancellationToken);
                    }
                }

                foreach (var toolUpdate in update.ToolCallUpdates)
                {
                    if (!toolCallAccumulators.TryGetValue(toolUpdate.Index, out var accumulator))
                    {
                        accumulator = new StreamingToolCallAccumulator { Index = toolUpdate.Index };
                        toolCallAccumulators.Add(toolUpdate.Index, accumulator);
                    }

                    if (!string.IsNullOrEmpty(toolUpdate.ToolCallId))
                    {
                        accumulator.ToolCallId = toolUpdate.ToolCallId;
                    }

                    if (!string.IsNullOrEmpty(toolUpdate.FunctionName))
                    {
                        accumulator.FunctionName = toolUpdate.FunctionName;
                    }

                    if (toolUpdate.FunctionArgumentsUpdate != null)
                    {
                        accumulator.Arguments.Append(toolUpdate.FunctionArgumentsUpdate);
                    }
                }
            }
        }
        catch (OperationCanceledException)
        {
            throw;
        }
        catch (Exception e) when (!receivedAnyUpdate)
        {
            // 首个数据帧之前失败(常见于服务端不支持流式工具调用),交由调用方回退非流式
            _logger.LogWarning(
                e,
                "MCP streaming completion failed before first update, retry with non-streaming"
            );
            return null;
        }

        var toolCalls = new List<ChatToolCall>(toolCallAccumulators.Count);
        foreach (var accumulator in toolCallAccumulators.Values)
        {
            if (string.IsNullOrEmpty(accumulator.FunctionName))
            {
                continue;
            }

            toolCalls.Add(
                ChatToolCall.CreateFunctionToolCall(
                    accumulator.ToolCallId,
                    accumulator.FunctionName,
                    BinaryData.FromString(accumulator.Arguments.ToString())
                )
            );
        }

        return new ChatRoundResult
        {
            Text = contentBuilder.ToString(),
            ToolCalls = toolCalls,
            FinishReason = finishReason,
        };
    }

    private static async Task<ChatCompletion> CompleteChatAndAccumulateUsageAsync(
        ChatClient chatClient,
        IReadOnlyList<ChatMessage> sendMessage,
        ChatCompletionOptions completionOptions,
        ChatUsageAccumulator usage,
        CancellationToken cancellationToken
    )
    {
        var completion = (
            await chatClient.CompleteChatAsync(sendMessage, completionOptions, cancellationToken)
        ).Value;

        if (!string.IsNullOrWhiteSpace(completion.Model))
        {
            usage.ResponseModel = completion.Model;
        }

        if (completion.Usage != null)
        {
            usage.PromptTokens += completion.Usage.InputTokenCount;
            usage.CompletionTokens += completion.Usage.OutputTokenCount;
            usage.TotalTokens += completion.Usage.TotalTokenCount;
        }

        return completion;
    }

    private sealed class ChatUsageAccumulator
    {
        public string ResponseModel { get; set; } = "";

        public int PromptTokens { get; set; }

        public int CompletionTokens { get; set; }

        public int TotalTokens { get; set; }
    }

    private sealed class ChatRoundResult
    {
        public string Text { get; set; } = "";

        public List<ChatToolCall> ToolCalls { get; set; } = [];

        public ChatFinishReason? FinishReason { get; set; }

        /// <summary>
        /// 是否由流式补全产生(false 表示发生过非流式回退)
        /// </summary>
        public bool WasStreamed { get; set; } = true;
    }

    private sealed class StreamingToolCallAccumulator
    {
        public int Index { get; init; }

        public string ToolCallId { get; set; } = "";

        public string FunctionName { get; set; } = "";

        public StringBuilder Arguments { get; } = new();
    }

    private static string GetCompletionText(ChatCompletion? completion)
    {
        if (completion is null || completion.Content.Count == 0)
        {
            return "";
        }

        var builder = new StringBuilder();
        foreach (var part in completion.Content)
        {
            if (!string.IsNullOrEmpty(part.Text))
            {
                builder.Append(part.Text);
            }
        }

        return builder.ToString();
    }

    private static OpenAiChatOptions LoadOptions(IConfiguration configuration)
    {
        var section = configuration.GetSection(ConfigurationSectionName);
        if (!section.Exists())
        {
            throw new InvalidConfigurationException(
                $"{ConfigurationSectionName} configuration section is missing"
            );
        }

        var options = section.Get<OpenAiChatOptions>() ?? new OpenAiChatOptions();
        if (string.IsNullOrWhiteSpace(options.Endpoint))
        {
            throw new InvalidConfigurationException("OpenAI:Endpoint configuration is missing");
        }

        if (string.IsNullOrWhiteSpace(options.ApiKey))
        {
            throw new InvalidConfigurationException("OpenAI:ApiKey configuration is missing");
        }

        if (string.IsNullOrWhiteSpace(options.Model))
        {
            throw new InvalidConfigurationException("OpenAI:Model configuration is missing");
        }

        return options;
    }

    private static ChatClient CreateChatClient(OpenAiChatOptions options)
    {
        return CreateChatClient(options, options.Model);
    }

    private static ChatClient CreateChatClient(OpenAiChatOptions options, string model)
    {
        var credential = new ApiKeyCredential(options.ApiKey);
        var clientOptions = new OpenAIClientOptions { Endpoint = new Uri(options.Endpoint) };
        return new ChatClient(model, credential: credential, clientOptions);
    }

    private static OpenAIModelClient CreateModelClient(OpenAiChatOptions options)
    {
        var credential = new ApiKeyCredential(options.ApiKey);
        var clientOptions = new OpenAIClientOptions { Endpoint = new Uri(options.Endpoint) };
        return new OpenAIModelClient(credential, clientOptions);
    }

    private OpenAiChatRequestOptions CreateRequestOptions()
    {
        return new OpenAiChatRequestOptions
        {
            Model = _defaults.Model,
            MaxOutputTokenCount = _defaults.MaxOutputTokenCount,
            Temperature = _defaults.Temperature,
            TopP = _defaults.TopP,
            FrequencyPenalty = _defaults.FrequencyPenalty,
        };
    }

    private static ChatCompletionOptions CreateCompletionOptions(OpenAiChatRequestOptions options)
    {
        return new ChatCompletionOptions
        {
            MaxOutputTokenCount = options.MaxOutputTokenCount,
            Temperature = options.Temperature,
            TopP = options.TopP,
            FrequencyPenalty = options.FrequencyPenalty,
            ResponseFormat = options.ResponseFormat,
        };
    }
}
⚠⚠    以下内容为AI分析的结果,请根据实际情况进行判断。

文件概述

src/Dpz.Core.Service.Network/OpenAiService.cs 是 Dpz.Core.Service.Network 项目中基于官方 OpenAI .NET SDK 的具体实现类,它实现了 IOpenAiService.cs 接口,对外提供三类能力:

  1. 普通流式聊天(SendMessageAsync)
  2. 模型列表查询(GetModelsAsync)
  3. 带 MCP 工具调用的多轮聊天(SendMessageWithMcpAsync)

它是同目录下已被标记 [Obsolete] 的旧实现(ChatCompletionService.cs、StreamResponseParser.cs 以及 Models/ 下若干旧模型)的替代者——旧模型类上的注释统一写着 use IOpenAiService。

构造与配置

  • 从 IConfiguration 读取 "OpenAI" 配置节,映射为 OpenAiChatOptions.cs(Endpoint / ApiKey / Model / MaxOutputTokenCount / Temperature / TopP / FrequencyPenalty)。
  • LoadOptions 会做三重校验:配置节缺失、Endpoint 为空、ApiKey 为空、Model 为空,任一不满足即抛 InvalidConfigurationException.cs。因此配置错误会在服务构造时立刻暴露,而不是等到首次请求。
  • 构造时创建两个长生命周期客户端:ChatClient(默认模型)与 OpenAIModelClient,两者都使用 ApiKeyCredential + OpenAIClientOptions { Endpoint },即支持指向任何兼容 OpenAI 协议的自建/第三方端点。
  • 注册方式见 NetworkServiceExtensions.cs:AddSingleton<IOpenAiService, OpenAiService>(),属于单例,因此 ChatClient 会被复用;当单次请求覆盖了 Model 时,代码会为该模型临时 CreateChatClient(_defaults, model) 新建一个客户端(不缓存)。

依赖与协作

协作对象作用
OpenAiChatRequestOptions.cs单次请求覆盖项,包含一个默认配置里没有的 ResponseFormat
OpenAiChatStreamResult.cs统一返回体(Model / Content / 三类 token 数),并提供 Deserialize<T> 供业务侧解析 JSON 输出
OpenAiMcpToolSupport.csMCP 工具 → OpenAI ChatTool 的转换、参数解析、结果格式化与截断、轮次钳制(1~16,默认 8)
ResponseResult.cs项目统一响应契约,WithOk / WithFail 决定成功或失败
ModelContextProtocol.Client.McpClient外部传入的已连接 MCP 客户端,负责 ListToolsAsync / CallToolAsync

实际调用方可见于 AnalyzeCodeHandler.cs:它通过 IInProcessMcpSessionFactory 创建进程内 MCP 会话,把 mcpSession.Client 传给 SendMessageWithMcpAsync,并用 onDeltaContentAsync 做 100ms 批量的流式进度推送、用 onToolCallAsync 把工具名转成中文进度提示。

三个公开方法的行为细节

1. SendMessageAsync(单轮流式)

  • 入参 messages 为空直接 WithFail("Messages cannot be empty")。
  • 先用默认值构造 CreateRequestOptions(),再让调用方的 options 委托覆盖,最后按 Model 是否等于默认值决定复用还是新建 ChatClient。
  • 使用 CompleteChatStreamingAsync,逐帧把 ContentUpdate 文本追加进 StringBuilder,同时回调 onDeltaContentAsync(并把 cancellationToken 透传给业务回调,便于调用方主动中止)。
  • 逐帧记录 update.Model 与 update.Usage。
  • token 统计:注意此处是“覆盖”语义(usage = update.Usage),只在流中出现用量帧时取最后一次的值;而 MCP 路径是“累加”语义。
  • 返回 OpenAiChatStreamResult,无用量时三项 token 保持 null。
  • OperationCanceledException 会记录日志后原样抛出(不算业务失败);其他异常返回 WithFail(e.Message)。

2. GetModelsAsync

直接调用 _modelClient.GetModelsAsync 并返回 models.Value.ToList(),异常处理策略与上一致(取消抛出、其它异常转 WithFail)。

3. SendMessageWithMcpAsync(工具调用循环)

这是本文件最复杂的部分,关键设计:

  • 先 mcpClient.ListToolsAsync;若没有任何工具,则退化为直接调用 SendMessageAsync,不进入循环。
  • 把每个 MCP 工具通过 OpenAiMcpToolSupport.ToChatTool 加进 completionOptions.Tools。
  • 轮次钳制为 ClampMaxToolRounds(<1 视作默认 8,上限 16)。
  • 循环 round = 0..allowedRounds(maxToolRounds 为 8 时最多 9 轮请求):
    • 有剩余轮次时 ToolChoice = Auto,最后一轮改为 None。注释明确解释了不清空 Tools 只禁止选择的原因:清空工具声明会让部分兼容端点把调用写进正文文本。
    • 每轮结束后把 AssistantChatMessage(roundResult.ToolCalls) 追加进消息列表,再执行工具调用,把结果以 ToolChatMessage(toolCall.Id, toolText) 追加,形成标准的 function-calling 往返。
    • 无工具调用或已不允许工具调用则跳出。
  • 结束后做三重收尾校验:无轮次结果、最后一轮仍残留未执行工具调用、最终正文为空,分别返回失败信息。
  • 用量通过 ChatUsageAccumulator 跨轮累加,最终只在大于 0 时写入结果。

工具执行 ExecuteToolCallsAsync:

  • 每轮每工具先记日志、触发 onToolCallAsync(函数名, 原始 JSON 参数)。
  • 参数经 ParseArguments 解析为字典;调用 mcpClient.CallToolAsync;结果经 FormatToolResult 展平并截断(上限 24,000 字符)。
  • 单个工具失败不会中断整个对话:捕获非取消异常,记录告警后把 "Error: ..." 文本作为工具结果回填给模型。

流式/非流式回退机制

CompleteChatRoundAsync 与 TryCompleteChatStreamingRoundAsync 共同实现了一个容错点:

  • 默认优先流式;TryCompleteChatStreamingRoundAsync 手动获取异步枚举器,一旦收到过任何帧之后再出错,异常照常抛出;若在首个数据帧之前就失败(注释指出常见于服务端不支持流式工具调用),则记录告警并返回 null,由上层回退到 CompleteChatAsync 非流式补全。
  • 回退后 ChatRoundResult.WasStreamed = false,并在循环中通过 preferStreaming = roundResult.WasStreamed 让后续所有轮次都不再尝试流式,避免反复踩同一个坑。
  • 流式分支会把工具调用的增量片段按 Index 汇总到 StreamingToolCallAccumulator(拼 ToolCallId、FunctionName、Arguments 字符串),最后用 ChatToolCall.CreateFunctionToolCall(..., BinaryData.FromString(...)) 还原成完整调用;缺少函数名的片段会被丢弃。
  • 非流式分支在拿到文本后,若没有工具调用且有 onDeltaContentAsync,会以一次整段回调的方式模拟流式推送;流式分支则逐帧推送。

私有辅助与嵌套类型

  • CreateRequestOptions / CreateCompletionOptions:前者把默认配置投射到请求选项,后者把请求选项投射到 SDK 的 ChatCompletionOptions(含 ResponseFormat)。
  • GetCompletionText:拼接 ChatCompletion.Content 中的非空文本片段。
  • 私有嵌套类型 ChatUsageAccumulator(跨轮累加 Model 与三类 token)、ChatRoundResult(单轮产物 + WasStreamed 标记)、StreamingToolCallAccumulator(单条工具调用的流式拼接缓冲,仅在本文件内使用)。
  • 顶部 using ChatMessage = OpenAI.Chat.ChatMessage; 是为了避免与项目内旧模型 Dpz.Core.Service.Network.Models.ChatMessage 冲突。

MCP 多轮流程示意

flowchart TD
    A[SendMessageWithMcpAsync] --> B{messages 为空?}
    B -- 是 --> F1[WithFail: Messages cannot be empty]
    B -- 否 --> C[ListToolsAsync 发现 MCP 工具]
    C --> D{工具数为 0?}
    D -- 是 --> E[退化为 SendMessageAsync 纯流式]
    D -- 否 --> F[构造 RequestOptions/CompletionOptions 并注入 Tools]
    F --> G[round = 0..allowedRounds]
    G --> H{round < allowedRounds?}
    H -- 是 --> I[ToolChoice = Auto]
    H -- 否 --> J[ToolChoice = None, 保留 Tools 声明]
    I --> K[CompleteChatRoundAsync]
    J --> K
    K --> L{preferStreaming?}
    L -- 是 --> M[TryCompleteChatStreamingRoundAsync]
    M --> N{首帧前失败?}
    N -- 是 --> O[记录告警, 回退 CompleteChatAsync]
    N -- 否 --> P[累加用量/文本/工具调用片段]
    L -- 否 --> O
    O --> Q[返回 ChatRoundResult, WasStreamed=false]
    P --> R[返回 ChatRoundResult]
    Q --> S{有工具调用且仍允许工具?}
    R --> S
    S -- 是 --> T[追加 AssistantChatMessage]
    T --> U[ExecuteToolCallsAsync 逐个 CallToolAsync]
    U --> V[逐个追加 ToolChatMessage]
    V --> G
    S -- 否 --> W{lastRound 为空?}
    W -- 是 --> F2[WithFail: Model returned no completion]
    W -- 否 --> X{最后一轮仍有待执行工具调用?}
    X -- 是 --> F3[WithFail: 最后一轮仍请求工具调用]
    X -- 否 --> Y{Content 为空?}
    Y -- 是 --> F4[WithFail: Model returned empty content]
    Y -- 否 --> Z[WithOk: OpenAiChatStreamResult]

值得注意的行为特征

  • 取消与失败语义分离:三个公开方法都统一采用“取消向上抛、其它异常转 WithFail”的策略,调用方需要自行区分“真失败”与“被取消”。
  • _defaults 是实例字段而非 IOptions<OpenAiChatOptions>,配置在单例首次构造时固化;配置热更新不会生效。
  • SendMessageAsync 的 token 为覆盖式取值,SendMessageWithMcpAsync 为累加式取值,两者在多次用量帧场景下的结果可能不同。
  • maxToolRounds = 8 意味着最多 8 + 1 次模型请求;最后一轮只负责产出最终文本,不再执行工具。

(以上所有符号与调用关系均通过读取同仓库文件确认;McpClient / ChatClient 等 SDK 类型的具体内部实现未在本仓库中,故未展开。)

评论加载中...