6. 简单将原生代码改为流式请求

少哔哔,先上效果

1. 代码运行效果

2. 代码

2.1. ChatResponseUpdate

C# 复制代码
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

public sealed class ChatResponseUpdate
{
    public string? Text { get; init; }
    public string? RawJson { get; init; }   // 需要调试时很有用
}

2.2. ChatStreaming

C# 复制代码
using System.Net.Http.Headers;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;

public static class ChatStreaming
{
    public static async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
        HttpClient httpClient,
        string baseUrl,
        string model,
        object[] messages,
        double temperature = 0.7,
        int maxTokens = 1000,
        [EnumeratorCancellation] CancellationToken cancellationToken = default)
    {
        // 1) 请求体:关键是 stream=true
        var requestBody = new
        {
            model = model,
            messages = messages,
            temperature = temperature,
            max_tokens = maxTokens,
            stream = true
        };

        var json = JsonSerializer.Serialize(requestBody);
        using var content = new StringContent(json, Encoding.UTF8, "application/json");

        // 2) 用 ResponseHeadersRead:不要把整个响应缓冲到内存
        using var request = new HttpRequestMessage(HttpMethod.Post, $"{baseUrl}/chat/completions")
        {
            Content = content
        };

        using var response = await httpClient.SendAsync(
            request,
            HttpCompletionOption.ResponseHeadersRead,
            cancellationToken);

        response.EnsureSuccessStatusCode();

        // 3) SSE:逐行读
        await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
        using var reader = new StreamReader(stream, Encoding.UTF8);

        while (!reader.EndOfStream && !cancellationToken.IsCancellationRequested)
        {
            var line = await reader.ReadLineAsync();
            if (line is null) break;

            // SSE 空行:表示一个 event block 结束;这里我们按行解析即可
            if (line.Length == 0) continue;

            // 只关心 data: 行
            if (!line.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
                continue;

            var data = line.Substring("data:".Length).Trim();

            // 结束标志
            if (data == "[DONE]") yield break;

            // 解析 JSON chunk
            string? text = null;
            ChatResponseUpdate? update = null;

            try
            {
                using var doc = JsonDocument.Parse(data);
                var root = doc.RootElement;

                // OpenAI-compatible 常见路径:
                // choices[0].delta.content
                // 某些实现:choices[0].message.content(也可能在流里出现)
                if (root.TryGetProperty("choices", out var choices) && choices.GetArrayLength() > 0)
                {
                    var choice0 = choices[0];

                    // delta.content
                    if (choice0.TryGetProperty("delta", out var delta))
                    {
                        if (delta.TryGetProperty("content", out var c))
                            text = c.GetString();

                        // 有些模型把增量放在 delta.text
                        else if (delta.TryGetProperty("text", out var t))
                            text = t.GetString();
                    }

                    // message.content(少数兼容实现)
                    if (text is null && choice0.TryGetProperty("message", out var msg) &&
                        msg.TryGetProperty("content", out var mc))
                    {
                        text = mc.GetString();
                    }
                }

                update = new ChatResponseUpdate { Text = text, RawJson = data };
            }
            catch (JsonException)
            {
                // 解析失败:把原始 chunk 返回,方便你输出/定位
                update = new ChatResponseUpdate { Text = null, RawJson = data };
            }

            // text 可能为空(例如 tool call / finish_reason chunk),过滤或原样返回都行
            if (update is not null)
                yield return update;
        }
    }
}

2.3. Program

C# 复制代码
using System.Net.Http.Headers;


//// 配置Deepseek平台参数
//const string platformName = "deepseek";
//const string apiKey = "*************"; // 替换为您的API密钥
//const string baseUrl = "https://api.deepseek.com";
//const string model = "deepseek-chat";

//// 配置阿里云百炼平台参数
//const string platformName = "阿里云百炼";
//const string apiKey = "*************"; // 替换为您的API密钥
//const string baseUrl = "https://dashscope.aliyuncs.com/compatible-mode/v1";
//const string model = "qwen3-max-2025-09-23"; // 或其他模型如 qwen-turbo, qwen-max

//// 配置本地llama3大模型参数
//const string platformName = "本地llama3大模型";
//const string apiKey = ""; 
//const string baseUrl = "http://127.0.0.1:11434/v1";
//const string model = "llama3";


// 配置智谱大模型参数
const string platformName = "智谱大模型";
//const string apiKey = "*************"; // 替换为您的API密钥
const string baseUrl = "https://open.bigmodel.cn/api/paas/v4";
const string model = "glm-4.5";

// ================================
// HttpClient 初始化
// ================================

using var httpClient = new HttpClient
{
    Timeout = TimeSpan.FromSeconds(600)
};

httpClient.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", apiKey);

httpClient.DefaultRequestHeaders.Accept.Add(
    new MediaTypeWithQualityHeaderValue("text/event-stream"));
// 有的服务也接受 application/json,但 streaming 时声明 event-stream 更明确
// httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

// ================================
// messages 构建
// ================================

var messages = new object[]
{
    new
    {
        role = "system",
        content =
            "你是一个有经验的针对中文为母语的英语学习者提供英语学习服务的老师,你会把用户提供的英文句子中的中国初中英语教材之外的单词提取出来。把复数、过去式等转换为原型形式。超出英语学习范围的东西,请回复'这个问题我回答不了,换个问题吧。'"
    },
    new
    {
        role = "user",
        content =
            "Far out in the uncharted backwaters of the unfashionable end of the Western Spiral arm of the Galaxy lies a small unregarded yellow sun."
    }
};

// ================================
// 调用(流式)
// ================================

try
{
    Console.WriteLine($"正在调用{platformName}平台(流式)...\n");

    // 可选:支持 Ctrl+C 取消
    using var cts = new CancellationTokenSource();
    Console.CancelKeyPress += (_, e) =>
    {
        e.Cancel = true;
        cts.Cancel();
    };

    await foreach (var update in ChatStreaming.GetStreamingResponseAsync(
                       httpClient,
                       baseUrl,
                       model,
                       messages,
                       temperature: 0.7,
                       maxTokens: 1000,
                       cancellationToken: cts.Token))
    {
        // 绝大多数 chunk 只有少量增量文本
        if (!string.IsNullOrEmpty(update.Text))
            Console.Write(update.Text);
    }

    Console.WriteLine("\n\n[Done]");
}
catch (OperationCanceledException)
{
    Console.WriteLine("\n[Canceled]");
}
catch (HttpRequestException ex)
{
    Console.WriteLine($"\nHTTP请求错误: {ex.Message}");
}
catch (Exception ex)
{
    Console.WriteLine($"\n发生错误: {ex.Message}");
}

3. 流式请求的缺陷、成本和适用边界

先给结论:
流式请求不会"请求更多次",但会"更频繁地产生网络事件",并且在工程复杂度、可控性、计费观感等方面都有明显代价。


3.1. 一、最常见误解:流式会不会"网络请求更多"?

3.1.1.❌ 不会更多 HTTP 请求

流式 = 1 次 HTTP 请求 + 长连接

text 复制代码
Client  ────────────────┐
                         │  ← 一次 POST
Server ── data: chunk ──┤
Server ── data: chunk ──┤
Server ── data: chunk ──┤
Server ── data: [DONE] ─┘
  • 不是:每个 token 一个 HTTP 请求
  • 而是:一次请求,服务端不断 flush 数据

所以结论是:

指标 流式 非流式
HTTP 请求次数 1 1
TCP 连接 1 1
TLS 握手 1 1

👉 请求次数没有增加


3.1.2. ⚠️ 但:网络"事件"会更多

虽然只有一次 HTTP 请求,但:

  • Server 每生成一段 token 就 flush
  • Client 每次 ReadLineAsync() / MoveNextAsync()
  • TCP 层会产生更多小包(Nagle / flush 行为)

这带来一些副作用👇


3.2. 流式请求的核心缺陷(重点)

1️⃣ 网络层:小包多、吞吐效率低

问题本质
  • 流式输出通常是 token 级 / 短字符串
  • 每次 flush 的 payload 很小(几十字节)
后果
  • TCP 小包增多
  • 延迟敏感网络(跨境 / 移动网络)更容易抖动
  • 带宽利用率下降

在高 QPS 场景下(比如你未来做 API 服务)尤其明显


2️⃣ 服务端资源占用时间更长(容易被限流)

非流式
  • 服务器快速生成完 → 一次性返回 → 释放连接
流式
  • 连接被"长时间占用"
  • 服务器线程 / 协程 / socket 保持更久

结果:

  • 云厂商更容易:
    • 限制 streaming 并发数
    • 对 streaming 单独限流
    • 或收取更高"连接时长"成本

3️⃣ C# 侧复杂度显著增加(你已经踩坑了)

你现在已经遇到:

  • async IAsyncEnumerable
  • yield return 语法限制
  • SSE 解析
  • JSON 增量协议
  • CancellationToken 传递
  • DisposeAsync 语义

相比之下:

csharp 复制代码
var json = await response.Content.ReadAsStringAsync();

简单得多

📌 维护成本是实打实增加的


4️⃣ 错误处理更难(尤其是"半途失败")

非流式
  • 成功:返回完整 JSON
  • 失败:HTTP 错误 / JSON 错误
流式
  • 可能已经输出了一半文本
  • 中途网络断了 / JSON chunk 损坏
  • 你已经没法"回滚"输出

你只能:

  • [Error]
  • 或标记"未完整回答"

这在 自动化流程 / 任务型调用 中是致命缺点。


5️⃣ 结果不可重放 / 不可缓存

非流式
  • 返回一个完整 response
  • 可以:
    • 缓存
    • 重放
    • 做幂等校验
流式
  • 本质是"事件流"
  • 必须自己:
    • 聚合
    • 记录
    • 才能复现

否则日志里只有"输出过的一堆字符"。


6️⃣ 计费与统计更模糊(你看不到 usage)

很多平台在 streaming 时:

  • usage(token 统计)只在最后一个 chunk 给
  • 或干脆不给

这会导致:

  • 实时无法知道 token 消耗
  • 做成本控制很难
  • 对账更复杂

3.3. 流式请求的"隐藏成本"(工程实践中很重要)

1️⃣ 更难做重试

  • 非流式:失败 → 重试整个请求
  • 流式:
    • 已输出了一半
    • 你是"从头再来"还是"从中断点续"?
    • 大多数 API 不支持续流

2️⃣ 更难做 timeout 策略

  • 非流式:一个总 timeout
  • 流式:
    • 每个 chunk 都在刷新
    • 你是:
      • 以"首包时间"算超时?
      • 还是"最后一个 chunk 时间"?

容易写出 永不超时的请求


3️⃣ 监控与 APM 更复杂

在你这种 SkyWalking / Loki / TraceId 体系下:

  • 一个请求持续 20 秒
  • 中间产生几十次日志
  • trace 时间线很长
  • 告警容易误判为"慢请求"

3.4. 什么时候该用流式?(非常重要)

✅ 强烈建议用流式

  • 人机对话(Chat UI)
  • CLI 工具
  • 教学 / Demo / 体验型产品
  • 推理时间很长的大模型

❌ 不建议用流式

  • 后台任务
  • 自动化 pipeline
  • 对"完整结果一致性"敏感的业务
  • 需要强 retry / 幂等 / 缓存的场景
  • 数据分析 / ETL / workflow

很多时候,用户并不在乎"快 300ms 看见第一个字"


3.5.一句话总结

流式请求不是"请求更多",而是"连接更久、事件更多、复杂度更高"。

它换来的是:更低首字延迟 + 更强交互感 ,代价是:工程复杂度、稳定性、可控性

相关推荐
一叶星殇5 小时前
C# .NET 如何解决跨域(CORS)
开发语言·前端·c#·.net
JQLvopkk5 小时前
C#调用Unity实现设备仿真开发浅述
开发语言·unity·c#
zxy28472253016 小时前
使用Topshelf部署window后台服务(C#)
c#·安装·topshelf·后台服务
缺点内向7 小时前
C# 高效统计 Word 文档字数:告别手动,拥抱自动化
c#·自动化·word
skywalk81638 小时前
介绍一下 Backtrader量化框架(C# 回测快)
开发语言·c#·量化
Never_Satisfied9 小时前
C#数组去重方法总结
开发语言·c#
阿蒙Amon9 小时前
C#每日面试题-静态构造函数和普通构造函数区别
java·开发语言·c#
Java程序员威哥9 小时前
SpringBoot4.0+JDK25+GraalVM:云原生Java的性能革命与落地指南
java·开发语言·后端·python·云原生·c#
阿蒙Amon9 小时前
C#每日面试题-Task和ValueTask区别
java·开发语言·c#