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 接口,对外提供三类能力:
- 普通流式聊天(
SendMessageAsync) - 模型列表查询(
GetModelsAsync) - 带 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.cs | MCP 工具 → 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 类型的具体内部实现未在本仓库中,故未展开。)
AI 正在分析代码…
评论加载中...