本文记录使用 Microsoft.Extensions.AI 对接 DeepSeek 模型,开启思考模式并使用工具调用时遇到的 reasoning_content 相关错误的解决方法,可直接配合 Microsoft Agent Framework 使用

本文内容由 AI 辅助编写

最近我尝试用 OpenAI 官方 SDK 以兼容模式对接 DeepSeek 模型,开启深度思考模式之后,如果调用了自定义工具,就会在工具调用完成后发起下一轮请求的时候抛出 HTTP 400 错误,错误内容为 The \reasoning_content` in the thinking mode must be passed back to the API`

翻查 DeepSeek 官方文档 https://api-docs.deepseek.com/zh-cn/guides/thinking_mode#%E5%B7%A5%E5%85%B7%E8%B0%83%E7%94%A8 可知,问题出在上下文回传的逻辑上:DeepSeek 的思考模式下,模型返回的 reasoning_content 思考内容,在后续的上下文请求里必须完整带回,包括工具调用完成后的续传请求。但是官方的 OpenAI .NET SDK 并没有适配 reasoning_content 这个扩展字段,在拼接上下文时会直接丢弃这部分内容,就触发了 API 的参数校验错误

为了解决这个问题,我采用了 walterlv 封装的 DeepSeekChatClient 实现,它完全兼容 Microsoft.Extensions.AI 的 IChatClient 接口,原生支持了 DeepSeek 的思考模式和 reasoning_content 的上下文回传逻辑,完美解决了这个问题。我还专门将其封装为 Microsoft.Extensions.AI.DeepSeek 库,可以直接在项目中引入使用。

项目配置

Microsoft.Extensions.AI.DeepSeek 库支持 net6.0、net8.0 和 net10.0 框架,依赖 Microsoft.Extensions.AI 10.5.2 版本,项目的 csproj 配置如下:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFrameworks>net10.0;net6.0;net8.0</TargetFrameworks>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <LangVersion>latest</LangVersion>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.Extensions.AI" Version="10.5.2" />
  </ItemGroup>
</Project>

你只需要将这个项目添加到你的解决方案中,或者打包为 NuGet 包引入即可使用。

核心实现原理

DeepSeekChatClient 解决问题的核心逻辑是在构建请求消息的时候,会将上下文里的 TextReasoningContent 内容序列化为 reasoning_content 字段回传给 API,不会丢失思考内容。对应的实现代码如下:

foreach (var content in message.Contents)
{
    switch (content)
    {
        case TextContent textContent when !string.IsNullOrEmpty(textContent.Text):
            textBuilder.Append(textContent.Text);
            break;
        case TextReasoningContent reasoningContent when !string.IsNullOrEmpty(reasoningContent.Text):
            // 收集思考内容,后续回传给 API
            reasoningBuilder.Append(reasoningContent.Text);
            break;
        case FunctionCallContent functionCall:
            toolCalls ??= [];
            toolCalls.Add(new JsonObject
            {
                ["id"] = functionCall.CallId,
                ["type"] = "function",
                ["function"] = new JsonObject
                {
                    ["name"] = functionCall.Name,
                    ["arguments"] = JsonSerializer.Serialize(functionCall.Arguments, SerializerOptions),
                },
            });
            break;
    }
}
// 构建请求时带上 reasoning_content 字段
yield return new JsonObject
{
    ["role"] = role,
    ["content"] = text,
    ["reasoning_content"] = reasoningBuilder.Length > 0 ? reasoningBuilder.ToString() : null,
    ["tool_calls"] = toolCalls,
};

整个代码

namespace Microsoft.Extensions.AI.DeepSeek;

using Microsoft.Extensions.AI;

using System.Net.Http.Headers;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;

public sealed partial class DeepSeekChatClient : IChatClient
{
    private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
    {
        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
    };

    private readonly HttpClient _httpClient;
    private readonly bool _ownsHttpClient;
    private readonly Uri _baseUri;
    private readonly string _apiKey;
    private readonly string _defaultModelId;
    private readonly bool _enableThinkingMode;
    private readonly int _reasoningBudgetTokens;
    private readonly ChatClientMetadata _metadata;

    public DeepSeekChatClient
    (
        string apiKey,
        string modelId,
        string baseUrl = "https://api.deepseek.com/v1",
        bool enableThinkingMode = true,
        int reasoningBudgetTokens = 8000,
        HttpClient? httpClient = null
    )
    {
        if (string.IsNullOrWhiteSpace(apiKey))
        {
            throw new ArgumentException("Value cannot be null or whitespace.", nameof(apiKey));
        }

        if (string.IsNullOrWhiteSpace(modelId))
        {
            throw new ArgumentException("Value cannot be null or whitespace.", nameof(modelId));
        }

        if (string.IsNullOrWhiteSpace(baseUrl))
        {
            throw new ArgumentException("Value cannot be null or whitespace.", nameof(baseUrl));
        }

        _httpClient = httpClient ?? new HttpClient();
        _ownsHttpClient = httpClient is null;
        _baseUri = new Uri($"{baseUrl.AsSpan().TrimEnd('/')}/", UriKind.Absolute);
        _apiKey = apiKey;
        _defaultModelId = modelId;
        _enableThinkingMode = enableThinkingMode;
        _reasoningBudgetTokens = reasoningBudgetTokens;
        _metadata = new ChatClientMetadata("DeepSeek", _baseUri, modelId);
    }

    public async Task<ChatResponse> GetResponseAsync(
        IEnumerable<ChatMessage> messages,
        ChatOptions? options = null,
        CancellationToken cancellationToken = default)
    {
        ArgumentNullException.ThrowIfNull(messages);

        List<AIContent> responseContents = [];
        UsageDetails? usage = null;
        ChatFinishReason? finishReason = null;
        string? responseId = null;
        string? modelId = null;
        DateTimeOffset? createdAt = null;
        object? rawRepresentation = null;
        ChatRole role = ChatRole.Assistant;

        await foreach (var update in GetStreamingResponseAsync(messages, options, cancellationToken))
        {
            if (update.Role is { } updateRole)
            {
                role = updateRole;
            }

            responseId ??= update.ResponseId;
            modelId ??= update.ModelId;
            createdAt ??= update.CreatedAt;
            finishReason = update.FinishReason ?? finishReason;
            rawRepresentation = update.RawRepresentation ?? rawRepresentation;

            foreach (var content in update.Contents)
            {
                if (content is UsageContent usageContent)
                {
                    usage = usageContent.Details;
                    continue;
                }

                responseContents.Add(content);
            }
        }

        var responseMessage = new ChatMessage(role, responseContents)
        {
            CreatedAt = createdAt,
            MessageId = responseId,
            RawRepresentation = rawRepresentation,
        };

        return new ChatResponse(responseMessage)
        {
            ResponseId = responseId,
            ModelId = modelId ?? options?.ModelId ?? _defaultModelId,
            CreatedAt = createdAt,
            FinishReason = finishReason,
            Usage = usage,
            RawRepresentation = rawRepresentation,
        };
    }

    public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
        IEnumerable<ChatMessage> messages,
        ChatOptions? options = null,
        [EnumeratorCancellation] CancellationToken cancellationToken = default)
    {
        ArgumentNullException.ThrowIfNull(messages);

        var requestJson = BuildRequest(messages, options);
        using var request = new HttpRequestMessage(HttpMethod.Post, new Uri(_baseUri, "chat/completions"))
        {
            Content = new StringContent(requestJson.ToJsonString(SerializerOptions), Encoding.UTF8, "application/json"),
        };

        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _apiKey);

        using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
        if (!response.IsSuccessStatusCode)
        {
            var errorBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
            throw new InvalidOperationException(MapErrorToMessage((int) response.StatusCode, errorBody));
        }

        using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
        using var reader = new StreamReader(stream, Encoding.UTF8);

        string? responseId = null;
        string? modelId = null;
        DateTimeOffset? createdAt = null;
        ChatFinishReason? finishReason = null;
        UsageDetails? usage = null;
        var toolCallAccumulators = new Dictionary<int, ToolCallAccumulator>();

        await foreach (var chunk in ReadSseChunksAsync(reader, cancellationToken))
        {
            responseId ??= chunk.Id;
            modelId ??= chunk.Model;
            if (createdAt is null && chunk.Created is { } created)
            {
                createdAt = DateTimeOffset.FromUnixTimeSeconds(created);
            }

            var choice = chunk.Choices?.FirstOrDefault();
            if (choice?.Delta is { } delta)
            {
                List<AIContent> contents = [];

                if (!string.IsNullOrEmpty(delta.ReasoningContent))
                {
                    contents.Add(new TextReasoningContent(delta.ReasoningContent)
                    {
                        RawRepresentation = chunk,
                    });
                }

                if (!string.IsNullOrEmpty(delta.Content))
                {
                    contents.Add(new TextContent(delta.Content)
                    {
                        RawRepresentation = chunk,
                    });
                }

                if (delta.ToolCalls is { Length: > 0 })
                {
                    foreach (var toolCall in delta.ToolCalls)
                    {
                        if (!toolCallAccumulators.TryGetValue(toolCall.Index, out var accumulator))
                        {
                            accumulator = new ToolCallAccumulator();
                            toolCallAccumulators[toolCall.Index] = accumulator;
                        }

                        accumulator.Apply(toolCall);
                    }
                }

                if (contents.Count > 0)
                {
                    yield return new ChatResponseUpdate(ChatRole.Assistant, contents)
                    {
                        ResponseId = responseId,
                        ModelId = modelId,
                        CreatedAt = createdAt,
                        RawRepresentation = chunk,
                    };
                }
            }

            if (!string.IsNullOrWhiteSpace(choice?.FinishReason))
            {
                finishReason = ParseFinishReason(choice.FinishReason);
            }

            if (chunk.Usage is { } chunkUsage)
            {
                usage = CreateUsageDetails(chunkUsage);
            }
        }

        List<AIContent> finalContents = [];
        foreach (var toolCall in toolCallAccumulators.OrderBy(static pair => pair.Key).Select(static pair => pair.Value))
        {
            finalContents.Add(toolCall.ToFunctionCallContent());
        }

        if (usage is not null)
        {
            finalContents.Add(new UsageContent(usage));
        }

        if (finalContents.Count > 0 || finishReason is not null)
        {
            yield return new ChatResponseUpdate(ChatRole.Assistant, finalContents)
            {
                ResponseId = responseId,
                ModelId = modelId ?? options?.ModelId ?? _defaultModelId,
                CreatedAt = createdAt,
                FinishReason = finishReason,
            };
        }
    }

    public object? GetService(Type serviceType, object? serviceKey = null)
    {
        ArgumentNullException.ThrowIfNull(serviceType);

        if (serviceKey is not null)
        {
            return null;
        }

        if (serviceType.IsInstanceOfType(this))
        {
            return this;
        }

        if (serviceType.IsInstanceOfType(_metadata))
        {
            return _metadata;
        }

        if (serviceType.IsInstanceOfType(_httpClient))
        {
            return _httpClient;
        }

        return null;
    }

    public void Dispose()
    {
        if (_ownsHttpClient)
        {
            _httpClient.Dispose();
        }
    }

    private JsonObject BuildRequest(IEnumerable<ChatMessage> messages, ChatOptions? options)
    {
        var request = new JsonObject
        {
            ["model"] = options?.ModelId ?? _defaultModelId,
            ["stream"] = true,
            ["stream_options"] = new JsonObject { ["include_usage"] = true },
            ["messages"] = BuildMessages(messages, options),
        };

        if (options?.Temperature is { } temperature)
        {
            request["temperature"] = JsonValue.Create(temperature);
        }

        if (options?.TopP is { } topP)
        {
            request["top_p"] = JsonValue.Create(topP);
        }

        if (options?.MaxOutputTokens is { } maxOutputTokens)
        {
            request["max_tokens"] = JsonValue.Create(maxOutputTokens);
        }

        if (options?.StopSequences is { Count: > 0 } stopSequences)
        {
            var stop = new JsonArray();
            foreach (var stopSequence in stopSequences)
            {
                stop.Add(stopSequence);
            }

            request["stop"] = stop;
        }

        if (_enableThinkingMode)
        {
            request["thinking"] = new JsonObject
            {
                ["type"] = "enabled",
                ["budget_tokens"] = _reasoningBudgetTokens,
            };
        }

        var tools = BuildTools(options?.Tools);
        if (tools.Count > 0)
        {
            request["tools"] = tools;
            request["tool_choice"] = "auto";
        }

        return request;
    }

    private static JsonArray BuildMessages(IEnumerable<ChatMessage> messages, ChatOptions? options)
    {
        JsonArray jsonMessages = [];

        if (!string.IsNullOrWhiteSpace(options?.Instructions))
        {
            jsonMessages.Add(new JsonObject
            {
                ["role"] = "system",
                ["content"] = options.Instructions,
            });
        }

        foreach (var message in messages)
        {
            foreach (var jsonMessage in BuildMessages(message))
            {
                jsonMessages.Add(jsonMessage);
            }
        }

        return jsonMessages;
    }

    private static IEnumerable<JsonObject> BuildMessages(ChatMessage message)
    {
        ArgumentNullException.ThrowIfNull(message);

        if (message.Role == ChatRole.Tool)
        {
            foreach (var content in message.Contents)
            {
                if (content is FunctionResultContent functionResult)
                {
                    yield return new JsonObject
                    {
                        ["role"] = "tool",
                        ["tool_call_id"] = functionResult.CallId,
                        ["content"] = SerializeToolResult(functionResult.Result),
                    };
                }
            }

            yield break;
        }

        string? text = null;
        string? reasoning = null;
        JsonArray? toolCalls = null;

        if (message.Contents.Count > 0)
        {
            var textBuilder = new StringBuilder();
            var reasoningBuilder = new StringBuilder();

            foreach (var content in message.Contents)
            {
                switch (content)
                {
                    case TextContent textContent when !string.IsNullOrEmpty(textContent.Text):
                        textBuilder.Append(textContent.Text);
                        break;
                    case TextReasoningContent reasoningContent when !string.IsNullOrEmpty(reasoningContent.Text):
                        reasoningBuilder.Append(reasoningContent.Text);
                        break;
                    case FunctionCallContent functionCall:
                        toolCalls ??= [];
                        toolCalls.Add(new JsonObject
                        {
                            ["id"] = functionCall.CallId,
                            ["type"] = "function",
                            ["function"] = new JsonObject
                            {
                                ["name"] = functionCall.Name,
#if NET8_0_OR_GREATER
                                ["arguments"] = JsonSerializer.Serialize(functionCall.Arguments, SourceGenerationContext.Default.Options),
#else
                                ["arguments"] = JsonSerializer.Serialize(functionCall.Arguments, SerializerOptions),
#endif
                            },
                        });
                        break;
                }
            }

            text = textBuilder.Length > 0 ? textBuilder.ToString() : null;
            reasoning = reasoningBuilder.Length > 0 ? reasoningBuilder.ToString() : null;
        }
        else if (!string.IsNullOrWhiteSpace(message.Text))
        {
            text = message.Text;
        }

        var role = message.Role == ChatRole.System ? "system"
            : message.Role == ChatRole.Assistant ? "assistant"
            : "user";

        yield return new JsonObject
        {
            ["role"] = role,
            ["content"] = text,
            ["reasoning_content"] = reasoning,
            ["tool_calls"] = toolCalls,
        };
    }

    private static JsonArray BuildTools(IList<AITool>? tools)
    {
        JsonArray jsonTools = [];
        if (tools is null)
        {
            return jsonTools;
        }

        foreach (var tool in tools)
        {
            if (tool is not AIFunctionDeclaration function)
            {
                continue;
            }

            jsonTools.Add(new JsonObject
            {
                ["type"] = "function",
                ["function"] = new JsonObject
                {
                    ["name"] = function.Name,
                    ["description"] = function.Description,
                    ["parameters"] = ToJsonNode(function.JsonSchema) ?? new JsonObject(),
                },
            });
        }

        return jsonTools;
    }

    private static JsonNode? ToJsonNode(object? value)
    {
        return value switch
        {
            null => null,
            JsonNode node => node.DeepClone(),
            JsonElement element => JsonNode.Parse(element.GetRawText()),
            JsonDocument document => JsonNode.Parse(document.RootElement.GetRawText()),
            string text when !string.IsNullOrWhiteSpace(text) => JsonNode.Parse(text),
#if NET8_0_OR_GREATER
            _ => JsonSerializer.SerializeToNode(value, SourceGenerationContext.Default.Options),
#else
            _ => JsonSerializer.SerializeToNode(value, SerializerOptions),
#endif
        };
    }

    private static string SerializeToolResult(object? result)
    {
        return result switch
        {
            null => "null",
            string text => text,
            JsonElement element => element.GetRawText(),
            JsonDocument document => document.RootElement.GetRawText(),
#if NET8_0_OR_GREATER
            _ => JsonSerializer.Serialize(result, SourceGenerationContext.Default.Options),
#else
            _ => JsonSerializer.Serialize(result, SerializerOptions),
#endif
        };
    }

    private static async IAsyncEnumerable<DeepSeekChatChunk> ReadSseChunksAsync(
        StreamReader reader,
        [EnumeratorCancellation] CancellationToken cancellationToken)
    {
        while (true)
        {
#if NET8_0_OR_GREATER
            var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
#else
            var line = await reader.ReadLineAsync().ConfigureAwait(false);
#endif
            if (line is null)
            {
                yield break;
            }

            if (!line.StartsWith("data: ", StringComparison.Ordinal))
            {
                continue;
            }

            var payload = line["data: ".Length..];
            if (payload == "[DONE]")
            {
                yield break;
            }

            if (string.IsNullOrWhiteSpace(payload))
            {
                continue;
            }

            DeepSeekChatChunk? chunk;
            try
            {
#if NET8_0_OR_GREATER
                chunk = JsonSerializer.Deserialize(payload, SourceGenerationContext.Default.DeepSeekChatChunk);
#else
                chunk = JsonSerializer.Deserialize<DeepSeekChatChunk>(payload, SerializerOptions);
#endif
            }
            catch (JsonException)
            {
                continue;
            }

            if (chunk is not null)
            {
                yield return chunk;
            }
        }
    }

    private static UsageDetails CreateUsageDetails(DeepSeekUsage usage)
    {
        var usageDetails = new UsageDetails
        {
            InputTokenCount = usage.PromptTokens,
            OutputTokenCount = usage.CompletionTokens,
            TotalTokenCount = usage.TotalTokens > 0 ? usage.TotalTokens : usage.PromptTokens + usage.CompletionTokens,
            CachedInputTokenCount = usage.PromptCacheHitTokens,
            ReasoningTokenCount = usage.CompletionTokensDetails?.ReasoningTokens,
        };

        if (usage.PromptCacheMissTokens > 0)
        {
            usageDetails.AdditionalCounts = new AdditionalPropertiesDictionary<long>
            {
                ["prompt_cache_miss_tokens"] = usage.PromptCacheMissTokens,
            };
        }

        return usageDetails;
    }

    private static ChatFinishReason? ParseFinishReason(string? finishReason)
    {
        return finishReason switch
        {
            null => null,
            "stop" => ChatFinishReason.Stop,
            "length" => ChatFinishReason.Length,
            "tool_calls" => ChatFinishReason.ToolCalls,
            "content_filter" => ChatFinishReason.ContentFilter,
            _ => new ChatFinishReason(finishReason),
        };
    }

    private static string MapErrorToMessage(int statusCode, string responseBody)
    {
        string? errorCode = null;
        string? errorMessage = null;

        try
        {
#if NET8_0_OR_GREATER
            var error = JsonSerializer.Deserialize(responseBody, SourceGenerationContext.Default.DeepSeekErrorResponse);
#else
            var error = JsonSerializer.Deserialize<DeepSeekErrorResponse>(responseBody, SerializerOptions);
#endif
            errorCode = error?.Error?.Code;
            errorMessage = error?.Error?.Message;
        }
        catch (JsonException)
        {
        }

        var detail = errorCode switch
        {
            "insufficient_balance" => "账户余额不足,请充值后重试。",
            "rate_limit_exceeded" => "请求速率超限,请稍后重试。",
            "context_length_exceeded" => "输入内容超出模型上下文限制。",
            "invalid_api_key" => "API Key 无效,请检查配置。",
            "model_not_found" => "模型不存在,请检查模型名称。",
            "server_error" => "服务端内部错误,请稍后重试。",
            _ => null,
        };

        if (detail is not null)
        {
            return $"API 错误(HTTP {statusCode}, code={errorCode}):{detail}";
        }

        if (!string.IsNullOrWhiteSpace(errorMessage))
        {
            return $"API 错误(HTTP {statusCode}):{errorMessage}";
        }

        return $"API 请求失败,状态码 {statusCode}。";
    }

#if NET8_0_OR_GREATER
    [JsonSourceGenerationOptions(
        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
        PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
    [JsonSerializable(typeof(DeepSeekChatChunk))]
    [JsonSerializable(typeof(DeepSeekErrorResponse))]
    [JsonSerializable(typeof(Dictionary<string, JsonElement>))]
    [JsonSerializable(typeof(IDictionary<string, object>), GenerationMode = JsonSourceGenerationMode.Serialization)]
    private partial class SourceGenerationContext : JsonSerializerContext
    {
    }
#endif

    private sealed class ToolCallAccumulator
    {
        private string? _id;
        private string? _name;
        private readonly StringBuilder _arguments = new();

        public void Apply(DeepSeekToolCallDelta delta)
        {
            if (!string.IsNullOrWhiteSpace(delta.Id))
            {
                _id = delta.Id;
            }

            if (!string.IsNullOrWhiteSpace(delta.Function?.Name))
            {
                _name = delta.Function.Name;
            }

            if (!string.IsNullOrEmpty(delta.Function?.Arguments))
            {
                _arguments.Append(delta.Function.Arguments);
            }
        }

        public FunctionCallContent ToFunctionCallContent()
        {
            var argumentsJson = _arguments.Length > 0 ? _arguments.ToString() : "{}";
            return new FunctionCallContent(
                _id ?? Guid.NewGuid().ToString("N"),
                _name ?? string.Empty,
                ParseArguments(argumentsJson))
            {
                RawRepresentation = argumentsJson,
            };
        }

        private static Dictionary<string, object?> ParseArguments(string argumentsJson)
        {
            try
            {
#if NET8_0_OR_GREATER
                var json = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(argumentsJson, SourceGenerationContext.Default.Options);
#else
                var json = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(argumentsJson, SerializerOptions);
#endif
                if (json is null)
                {
                    return [];
                }

                return json.ToDictionary(static pair => pair.Key, static pair => (object?) pair.Value.Clone(), StringComparer.Ordinal);
            }
            catch (JsonException)
            {
                return [];
            }
        }
    }

    private sealed class DeepSeekChatChunk
    {
        [JsonPropertyName("id")]
        public string? Id { get; set; }

        [JsonPropertyName("model")]
        public string? Model { get; set; }

        [JsonPropertyName("created")]
        public long? Created { get; set; }

        [JsonPropertyName("choices")]
        public DeepSeekChoice[]? Choices { get; set; }

        [JsonPropertyName("usage")]
        public DeepSeekUsage? Usage { get; set; }
    }

    private sealed class DeepSeekChoice
    {
        [JsonPropertyName("delta")]
        public DeepSeekDelta? Delta { get; set; }

        [JsonPropertyName("finish_reason")]
        public string? FinishReason { get; set; }
    }

    private sealed class DeepSeekDelta
    {
        [JsonPropertyName("content")]
        public string? Content { get; set; }

        [JsonPropertyName("reasoning_content")]
        public string? ReasoningContent { get; set; }

        [JsonPropertyName("tool_calls")]
        public DeepSeekToolCallDelta[]? ToolCalls { get; set; }
    }

    private sealed class DeepSeekToolCallDelta
    {
        [JsonPropertyName("index")]
        public int Index { get; set; }

        [JsonPropertyName("id")]
        public string? Id { get; set; }

        [JsonPropertyName("function")]
        public DeepSeekFunctionDelta? Function { get; set; }
    }

    private sealed class DeepSeekFunctionDelta
    {
        [JsonPropertyName("name")]
        public string? Name { get; set; }

        [JsonPropertyName("arguments")]
        public string? Arguments { get; set; }
    }

    private sealed class DeepSeekUsage
    {
        [JsonPropertyName("prompt_tokens")]
        public int PromptTokens { get; set; }

        [JsonPropertyName("completion_tokens")]
        public int CompletionTokens { get; set; }

        [JsonPropertyName("total_tokens")]
        public int TotalTokens { get; set; }

        [JsonPropertyName("prompt_cache_hit_tokens")]
        public int PromptCacheHitTokens { get; set; }

        [JsonPropertyName("prompt_cache_miss_tokens")]
        public int PromptCacheMissTokens { get; set; }

        [JsonPropertyName("completion_tokens_details")]
        public DeepSeekCompletionTokensDetails? CompletionTokensDetails { get; set; }
    }

    private sealed class DeepSeekCompletionTokensDetails
    {
        [JsonPropertyName("reasoning_tokens")]
        public int? ReasoningTokens { get; set; }
    }

    private sealed class DeepSeekErrorResponse
    {
        [JsonPropertyName("error")]
        public DeepSeekError? Error { get; set; }
    }

    private sealed class DeepSeekError
    {
        [JsonPropertyName("code")]
        public string? Code { get; set; }

        [JsonPropertyName("message")]
        public string? Message { get; set; }
    }
}

同时这个实现还完整支持 DeepSeek 的所有扩展特性:流式响应输出、思考内容流式返回、工具调用完整流程、错误码友好转换、推理Token消耗统计等,甚至会把缓存命中、推理Token消耗这些扩展字段都映射到 UsageDetails 里,方便业务统计成本。

使用示例

初始化 DeepSeekChatClient 的方式非常简单,只需要传入你的 DeepSeek API Key 和要使用的模型ID即可,也可以根据业务需要配置思考模式开关、推理预算Token等参数。以下是完整的工具调用测试代码:

using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.DeepSeek;
using OpenAI;
using System.ClientModel;

// 读取你的 DeepSeek API Key,记得替换为你自己的 Key 文件路径
var keyFile = @"C:\lindexi\Work\deepseek.txt";
var key = File.ReadAllText(keyFile);

// 初始化 DeepSeekChatClient,默认开启思考模式
using var chatClient = new DeepSeekChatClient(key, "deepseek-v4-pro");

// 如果使用 OpenAIClient 对接,就会出现本文提到的错误
//var openAiClient = new OpenAIClient(new ApiKeyCredential(key), new OpenAIClientOptions()
//{
//    Endpoint = new Uri("https://api.deepseek.com/v1")
//});
//var openAiChatClient = openAiClient.GetChatClient("deepseek-v4-pro").AsIChatClient();

// 构建带工具调用的 AI Agent,这里注册了查询天气的工具
ChatClientAgent agent = chatClient
    .AsBuilder()
    .BuildAIAgent(tools: [AIFunctionFactory.Create(GetWeather)]);

var session = await agent.CreateSessionAsync();
bool isThinking = false;

// 流式获取响应内容,区分输出思考内容和最终回答
await foreach (var agentResponseUpdate in agent.RunStreamingAsync("北京天气怎样", session))
{
    foreach (var aiContent in agentResponseUpdate.Contents)
    {
        if (aiContent is TextReasoningContent textReasoningContent)
        {
            if (string.IsNullOrEmpty(textReasoningContent.Text))
            {
                continue;
            }
            isThinking = true;
            Console.Write(textReasoningContent.Text);
        }
        else if (aiContent is TextContent textContent)
        {
            if (string.IsNullOrEmpty(textContent.Text))
            {
                continue;
            }
            if (isThinking)
            {
                Console.WriteLine();
            }
            isThinking = false;
            Console.Write(textContent.Text);
        }
    }
}

Console.WriteLine();
Console.WriteLine("Hello, World!");

// 自定义的天气查询工具
string GetWeather(string city)
{
    return $"The weather in {city} is sunny.";
}

运行以上代码即可看到模型先输出思考过程,然后调用天气工具,最后返回最终回答,全程不会再出现 reasoning_content 相关的错误。

代码

本文代码放在 githubgitee 上,可以使用如下命令行拉取代码。我整个代码仓库比较庞大,使用以下命令行可以进行部分拉取,拉取速度比较快

先创建一个空文件夹,接着使用命令行 cd 命令进入此空文件夹,在命令行里面输入以下代码,即可获取到本文的代码

git init
git remote add origin https://gitee.com/lindexi/lindexi_gd.git
git pull origin bc29ceb1611cb60c264143c3b13e408346ae14e3

以上使用的是国内的 gitee 的源,如果 gitee 不能访问,请替换为 github 的源。请在命令行继续输入以下代码,将 gitee 源换成 github 源进行拉取代码。如果依然拉取不到代码,可以发邮件向我要代码

git remote remove origin
git remote add origin https://github.com/lindexi/lindexi_gd.git
git pull origin bc29ceb1611cb60c264143c3b13e408346ae14e3

获取代码之后,进入 SemanticKernelSamples/Microsoft.Agents.AI.Extensions 文件夹,即可获取到源代码

更多技术博客,请参阅 博客导航

其他异常情况

由于 DeepSeek 对工具要求比较严格,如工具名只能符合 ^[a-zA-Z0-9_-]+$ 正则条件。如果传入不符合正则约束的工具名,将会遇到 HTTP 400 错误:

HTTP 400:Invalid 'tools[0].function.name': string does not match pattern. Expected a string that matches the pattern '^[a-zA-Z0-9_-]+$'.”

解决方法为修改工具名,让工具名符合规范


本文会经常更新,请阅读原文: https://blog.lindexi.com/post/dotnet-%E5%AF%B9%E6%8E%A5-DeepSeek-%E6%A8%A1%E5%9E%8B%E5%B7%A5%E5%85%B7%E8%B0%83%E7%94%A8%E6%97%B6-400-%E9%94%99%E8%AF%AF.html ,以避免陈旧错误知识的误导,同时有更好的阅读体验。

如果你想持续阅读我的最新博客,请点击 RSS 订阅,推荐使用RSS Stalker订阅博客,或者收藏我的博客导航

知识共享许可协议 本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。欢迎转载、使用、重新发布,但务必保留文章署名林德熙(包含链接: https://blog.lindexi.com ),不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。如有任何疑问,请 与我联系

微软最具价值专家


无盈利,不卖课,做纯粹的技术博客

以下是广告时间

推荐关注 Edi.Wang 的公众号

欢迎进入 Eleven 老师组建的 .NET 社区

以上广告全是友情推广,无盈利