惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

P
Privacy & Cybersecurity Law Blog
SecWiki News
SecWiki News
T
Troy Hunt's Blog
Y
Y Combinator Blog
V
V2EX
美团技术团队
Last Week in AI
Last Week in AI
S
Security @ Cisco Blogs
IT之家
IT之家
博客园_首页
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
阮一峰的网络日志
阮一峰的网络日志
AI
AI
罗磊的独立博客
人人都是产品经理
人人都是产品经理
H
Hacker News: Front Page
N
News and Events Feed by Topic
P
Privacy International News Feed
V2EX - 技术
V2EX - 技术
Recent Commits to openclaw:main
Recent Commits to openclaw:main
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
GbyAI
GbyAI
L
LINUX DO - 热门话题
C
Cybersecurity and Infrastructure Security Agency CISA
Microsoft Azure Blog
Microsoft Azure Blog
Martin Fowler
Martin Fowler
月光博客
月光博客
WordPress大学
WordPress大学
Latest news
Latest news
Google DeepMind News
Google DeepMind News
S
Schneier on Security
N
Netflix TechBlog - Medium
腾讯CDC
T
Tailwind CSS Blog
TaoSecurity Blog
TaoSecurity Blog
S
Secure Thoughts
L
LINUX DO - 最新话题
Project Zero
Project Zero
Cyberwarzone
Cyberwarzone
D
DataBreaches.Net
Webroot Blog
Webroot Blog
B
Blog
www.infosecurity-magazine.com
www.infosecurity-magazine.com
S
SegmentFault 最新的问题
The GitHub Blog
The GitHub Blog
H
Help Net Security
L
LangChain Blog
A
Arctic Wolf
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻

元视角

.NET 生态下的 Agent 框架选型:从 ReAct 到原生推理 - 元视角 从「能用」到「好用」:LLM 流式响应实现方式的探索之路 - 元视角 当我用 2000 条聊天记录,让 AI 为我画一幅自画像 - 元视角 基于 Supabase 的 AI 应用开发探索 - 元视角 微博 × MCP:社交媒体新玩法解锁 - 元视角 四点钟海棠花未眠 - 元视角 Semantic Kernel × MCP:智能体的上下文增强探索 - 元视角 基于 K-Means 聚类分析实现人脸照片的快速分类 - 元视角 容器技术驱动下的代码沙箱实践与思考 - 元视角 温故而知新:后端通用查询方案的再思考 - 元视角 Semantic Kernel 视角下的 Text2SQL 实践与思考 - 元视角 关于 ChatGPT 的流式传输,你需要知道的一切 - 元视角 RAG 的是与非、Rewrite 和 Rerank - 元视角 使用 EFCore 和 PostgreSQL 实现向量存储及检索 - 元视角 基于 LLaMA 和 LangChain 实践本地 AI 知识库 - 元视角 使用 llama.cpp 在本地部署 AI 大模型的一次尝试 - 元视角 如何为 Git 配置多个 SSH Key - 元视角 C# 使用 LibUsbDotNet 实现 USB 设备检测 - 元视角 基于 C# 实现样式与数据分离的打印方案 - 元视角 基于 SVG 的图形交互方案实践 - 元视角 前端视频播放技术概览 - 元视角 温故而知新,再话 Python 动态导入 - 元视角 后 GPT 时代,NLP 不存在了? - 元视角 视频是不能 P 的系列:使用 Milvus 实现海量人脸快速检索 - 元视角 GDI+下字体大小自适应方案初探 - 元视角 小爱音箱集成 ChatGPT 的不完全教程 - 元视角 程序员视角下的三体世界随想 - 元视角 关于 Docker 容器配置信息的渐进式思考 - 元视角 在 Docker 容器内集成 Crontab 定时任务 - 元视角 为你的服务器集成 LDAP 认证 - 元视角 似花还似非花 - 元视角 视频是不能 P 的系列:使用 Dlib 实现人脸识别 - 元视角 浅议分布式链路追踪与日志的整合 - 元视角 关于 Git 大文件上传这件小事 - 元视角 .NET 进程内队列 Channel 的入门与应用 - 元视角 使用 Fody 实现 .NET 的静态编织 - 元视角 .NET Core + ELK 搭建可视化日志分析平台(下) - 元视角 聊一聊前端图片懒加载背后的故事 - 元视角 支持外部链接跳转的 Vue Router 扩展实现 - 元视角 视频是不能 P 的系列:OpenCV 和 Dlib 实现表情包 - 元视角 不得不说的 ASP.NET Core 集成测试 - 元视角 再议 DDD 视角下的 EFCore 与 领域事件 - 元视角 Vue.js 前端项目容器化部署实践极简教程 - 元视角 再见,人间四月天 - 元视角 Python 图像风格化迁移助力画家梦想 - 元视角 利用 ASP.NET Core 中的标头传播实现分布式链路追踪 - 元视角 利用 gRPC 实现文件的上传与下载 - 元视角 七种武器:延迟队列的原理和实现总结 - 元视角 gRPC 流式传输极简入门指南 - 元视角 Envoy 集成 Jaeger 实现分布式链路追踪 - 元视角 浅议非典型 Web 应用场景下的身份认证 - 元视角 gRPC 借助 Any 类型实现接口的泛化调用 - 元视角 分布式丛林探险系列之 Redis 集群模式 - 元视角 分布式丛林探险系列之 Redis 主从复制模式 - 元视角 通过 Python 预测 2021 年双十一交易额 - 元视角 gRPC 搭配 Swagger 实现微服务文档化 - 元视角 SSL/TLS 加密传输与数字证书的前世今生 - 元视角 使用 Python 自动识别防疫健康码 - 元视角 你不可不知的容器编排进阶技巧 - 元视角 ASP.NET Core 搭载 Envoy 实现 gRPC 服务代理 - 元视角 再话 AOP,从简化缓存操作说起 - 元视角 ASP.NET Core 搭载 Envoy 实现微服务身份认证(JWT) - 元视角 ASP.NET Core 搭载 Envoy 实现微服务的监控预警 - 元视角 ASP.NET Core 搭载 Envoy 实现微服务的反向代理 - 元视角 ASP.NET Core gRPC 打通前端世界的尝试 - 元视角 EFCore 实体命名约定库:EFCore.NamingConventions - 元视角 ASP.NET Core gRPC 集成 Polly 实现优雅重试 - 元视角 ASP.NET Core gRPC 健康检查的探索与实现 - 元视角 ASP.NET Core gRPC 拦截器的使用技巧分享 - 元视角 SnowNLP 使用自定义语料进行模型训练 - 元视角 使用 HttpMessageHandler 实现 HttpClient 请求管道自定义 - 元视角 ABP vNext 的实体与服务扩展技巧分享 - 元视角 ABP vNext 对接 Ant Design Vue 实现分页查询 - 元视角 源代码探案系列之 .NET Core 跨域中间件 CORS - 元视角 源代码探案系列之 .NET Core 限流中间件 AspNetCoreRateLimit - 元视角 源代码探案系列之 .NET Core 并发限制中间件 ConcurrencyLimiter - 元视角 通过 EmbededFileProvider 实现 Blazor 的静态文件访问 - 元视角 低代码,想说爱你不容易 - 元视角 记一次失败的 ThoughtWorks 面试经历 - 元视角 从 C# 1.0 到 C# 9.0,历代 C# 语言特性一览 - 元视角 通过 Python 分析 2020 年全年微博热搜数据 - 元视角 基于 Python 和 Selenium 实现 CSDN 一键三连自动化 - 元视角 使用多线程为你的 Python 爬虫提速的 N 种姿势,你会几种? - 元视角 实现网页长截图的常见思路总结 - 元视角 温故而知新,由 ADO.NET 与 Dapper 所联想到的 - 元视角 视频是不能 P 的系列:OpenCV 人脸检测 - 元视角 作为技术宅的我,是这样追鬼滅の刃的 - 元视角 使用 Python 抽取《半泽直树》原著小说人物关系 - 元视角 厉害了!打工人用 Python 分析西安市职位信息 - 元视角 使用 dotTrace 对 .NET 应用进行性能分析与优化 - 元视角 一道 HashSet 面试题引发的蝴蝶效应 - 元视角 基于选项模式实现.NET Core 的配置热更新 - 元视角 Dapper.Contrib 在 Oracle 环境下引发 ORA-00928 异常问题的解决 - 元视角 .NET Core 中对象池(Object Pool)的使用 - 元视角 利用 MySQL 的 Binlog 实现数据同步与订阅(下):EventBus 篇 - 元视角 利用 MySQL 的 Binlog 实现数据同步与订阅(中):RabbitMQ 篇 - 元视角 利用 MySQL 的 Binlog 实现数据同步与订阅(上):基础篇 - 元视角 记一次从已损坏的 Git 仓库中找回代码的经历 - 元视角 .NET Core 原生 DI 扩展之属性注入实现 - 元视角 .NET Core 原生 DI 扩展之基于名称的注入实现 - 元视角
浅议 CancellationToken 在前后端协同取消场景中的应用 - 元视角
飞鸿踏雪 · 2024-08-15 · via 元视角

两个月前,我写过一篇题为为《关于 ChatGPT 的流式传输,你需要知道的一切》的文章。当时,我主要聚焦于 “流式传输” 这个概念。因此,Server-Sent EventsWebSocket 等技术,便顺理成章地成为了我的写作内容。然而,当我们将视野扩展到整个生成式 AI 领域时,我们会发现 “取消” 是一个非常普遍的业务场景。尽管我曾在这篇文章中提到了 AbortControllerCancellationToken,但我并不认为我完全解决了当时的问题,即:如何让前、后端的取消动作真正地协同起来?言下之意,我希望前端的 AbortController 发起取消请求以后,后端的 CancellationToken 能够及时感知并响应这一变化。这一切就好比,AI 智能体固然可以通过 “观察” 来感知外部的变化,可当用户决定停止生成的时候,一切都应该戛然而止,无论你是不是为了节省那一点点 token。所以,当两个月前的子弹正中眉心时,我决定继续探讨这个话题。由此,便有了今天这篇稍显多余的博客。

前后端协同取消

我必须承认,在推崇前后端分离的当下,我这个想法难免显得不合时宜。可什么是合时宜呢?在刚刚落幕的巴黎奥运会上,35 岁的 “龙队” 马龙,斩获了个人第 6 枚奥运金牌。对此,这个被誉为 “六边形战士” 的男人表示,“只要心怀热爱,永远都是当打之年”。这是否说明,一切的不合时宜都是自我设限,而年龄不过是个数字。在以往的工作中,我接触的主要是 “Fire and Forget” 这类场景。特别是当一个任务相对短暂时,有没有真正地取消从来都不会成为讨论的重点。直到最近做 Agent 的时候,我发觉这一切其实可以做得更好,即便我的原动力是为了省钱。

async Task Main() {
    Console.WriteLine("[HeartBeat] 服务运行中,请按 Ctrl + C 键取消...");
    
    var cts = new CancellationTokenSource();
    Console.CancelKeyPress += (sender, e) => {
        e.Cancel = true;
        cts.Cancel();
    };

    try {
        await HeartBeatAsync(cts.Token);
    } catch (OperationCanceledException) {
        Console.WriteLine("[HeartBeat] 服务已停止.");
    }
}

首先,我们来考虑一般场景下的 CancellationToken 的使用,通常它需要搭配 CancellationTokenSource 来使用。当然,产生这一切的背景是 .NET 中提供了基于任务(Task)的异步编程模型 TAP,我们需要一种机制来处理任务的取消请求。考虑到线程本身只有一个 Abort() 方法,CancellationTokenSource 以及 CancellationToken 这种在多线程环境中传达取消请求、无需终止线程的技术应运而生。参考上述示例,CancellationTokenSource 在其中扮演着调度者的角色,它向 HeartBeatAsync() 这个方法传递了一个令牌,并在某个时间点通过 Cancel() 方法触发了取消动作。相对应地,在 HeartBeatAsync() 方法中,我们需要及时检查 CancellationToken 的 IsCancellationRequested 属性,以判断是否有取消请求产生,如以下代码片段所示:

async Task HeartBeatAsync(CancellationToken cancellationToken) {
    while (!cancellationToken.IsCancellationRequested) {
        Console.WriteLine($"[HeartBeat] {DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")}");
        await Task.Delay(1000, cancellationToken);
    }
}

可以注意到,async 关键字会造成对方法的异步化污染,类似地,CancellationToken 需要在所有的异步方法中传播下去,这可能会造成一定的副作用。关于这个问题,我们先暂时将其搁置在一边。现在,让我们来思考一个问题:在这个示例中取消请求由我们来发起,那么,在一个 Web 服务中,取消请求应该有谁来发起呢?你可能会说,你这个问题问得可真没水平,这肯定是客户端嘛!好,顺着这个思路继续深挖下去,客户端发起了取消请求以后,服务器端如何感知到这一变化的呢?在 ASP.NET Core 中,我们可以通过 HttpContext 中的 RequestAborted 属性来进行判断,它本身就是一个 CancellationToken。当客户端连接到服务器端时,ASP.NET Core 负责构建 HttpContext,其中的 RequestAborted 则来自于一个 CancellationTokenSource 实例;当客户端从服务器端断开连接时,框架将会调用这个 CancellationTokenSource 实例的 Cancel() 方法。下面是一个简化的流程示意图:

服务器端如何感知客户端的取消请求 服务器端如何感知客户端的取消请求

为了验证这个猜想,我们可以通过下面的代码来验证。设想我们拥有一个经典的接口 Echo(),它可以像一个复读机一样重复我们的话语。在此,我们人为设置了 2s 的延迟,目的是便于观察 RequestAborted 属性:

[HttpGet("echo")]
public async Task<string> Echo(string text) {
    _logger.LogInformation("[{0}] IsCancellationRequested: {1}", 
        DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"), 
        HttpContext.RequestAborted.IsCancellationRequested
    );

    await Task.Delay(1000 * 2);

    _logger.LogInformation("[{0}] IsCancellationRequested: {1}", 
        DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"), 
        HttpContext.RequestAborted.IsCancellationRequested
    );

    return text;
}

接下来,我们将编写客户端代码。运用前文中掌握的技巧,我们可以让 CancellationTokenSource 在 2s 以后触发取消请求。让我们来一起看看,这一变化将带来什么结果?

var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(2));

try {
    using var httpClient = new HttpClient();
    var response = await httpClient.GetStringAsync("https://localhost:7261/api/Chat/Echo?text=你好", cts.Token);
} catch (OperationCanceledException) {
    Console.WriteLine("请求已取消");
}

此时,我们可以注意到,RequestAborted 中的 IsCancellationRequested 中的属性,在 2s 前后发生了变化:

HttpContext 中 RequestAborted 的变化(1) HttpContext 中 RequestAborted 的变化(1)

作为对比,我们这里放一张正常请求的日志截图,相信大家可以都发现其中的差异:

HttpContext 中 RequestAborted 的变化(2) HttpContext 中 RequestAborted 的变化(2)

这说明什么呢?这表明,在 Web 服务中,我们可以利用 RequestAborted 来判断客户端是否发起了取消请求。实际上,我们还有更简单的做法,我们可以直接在控制器方法中添加一个 CancellationToken 类型的参数,ASP.NET Core 会自动将 RequestAborted 注入到其中,而这恰好解释了我们的疑问:我们都知道可以在 Controller 中使用这个参数,但我们从来没有想过这个参数从哪里来。对于《关于 ChatGPT 的流式传输,你需要知道的一切》这篇文章的困惑,此刻终于破云见日,这个当时没有解决的问题,现在看来竟然是如此的简单:

[HttpGet("streaming")]
[HttpPost("streaming")]
public async Task GetStreamingAsync(CancellationToken cancellationToken) {
    try {
        cancellationToken.ThrowIfCancellationRequested();

        var text = "天之道,损有余而补不足,是故虚胜实,不足胜有余。其意博,其理奥,其趣深,天地之象分,阴阳之候列,变化之由表,死生之兆彰,不谋而遗迹自同";

        HttpContext.Response.ContentType = "text/event-stream";

        foreach (var item in text) {
            var payload = JsonSerializer.Serialize(new { text = item.ToString() });
            var message = $"data: {payload}\n\n";

            await HttpContext.Response.WriteAsync(message, Encoding.UTF8, cancellationToken);
            await HttpContext.Response.Body.FlushAsync(cancellationToken);
            await Task.Delay(200);
        }

        await HttpContext.Response.WriteAsync("data: [DONE]", cancellationToken);
        await HttpContext.Response.Body.FlushAsync(cancellationToken);
        await HttpContext.Response.CompleteAsync();
    } catch (OperationCanceledException ex) {
        _logger.LogInformation("Operation is canceled.");
    }
}

此时,我们只需要在前端使用 fetch() 函数以及 AbortController,即可实现真正意义上的取消,如下图所示:

前端发起取消请求 前端发起取消请求

日志表明,此刻后端成功了响应了前端发起的取消请求:

后端响应取消请求 后端响应取消请求

当然,此前我用 SignalR 实现的双向取消机制,现在同样可以工作:

public async Task Generate(string requestId, string prompt) {
    var cts = new CancellationTokenSource();
    _cancellationTokens[requestId] = cts;

    try {
        cts.Token.ThrowIfCancellationRequested();

        await foreach (var item in _textGenerator.Generate(prompt, cts.Token)) {
            await Clients.Caller.SendAsync("ReceiveChunks", JsonSerializer.Serialize(new { text = item }), requestId, cts.Token);
        }
    } catch (OperationCanceledException ex) {
        _logger.LogInformation("The task is canceled.");
    }

}

public async Task Cancel(string requestId) {
    if (_cancellationTokens.TryGetValue(requestId, out var cts)) {
        await cts.CancelAsync();
        await Clients.Caller.SendAsync("GenerationCancelled", requestId, cts.Token);

        _cancellationTokens.Remove(requestId);
    }
}

这个时候,你可能会意识到,你的代码充被着大量的 CancellationToken 入侵了。例如,你会注意到,这里我必须将这个令牌传递给 ITextGenerator 这个接口,而如果该接口内部还有更多的依赖项,这无疑就会变成一种莫名的负担。我一直在考虑优化 Wikit 在 “停止生成” 功能上的代码,现在看来,“长痛不如短痛”,或许我应该尽快开始这块的工作,虽然我预感到这个过程可能会有些痛苦。如图所示,下面是一种解决 CancellationToken 侵入性问题的解决思路,即通过 IHttpContextAccessor 接口封装一个全新的 ICancellationTokenProvider 接口:

interface ICancellationTokenProvider
{
    CancellationToken GetCancellationToken();
    Task<CancellationToken> GetCancellationTokenAsync();
}

一个基本的实现如下。此时,我们只需要在应该 “取消” 的地方,注入该服务即可:

class CancellationTokenProvider : ICancellationTokenProvider
{
    private readonly IHttpContextAccessor _httpContextAccessor;
    public CancellationTokenProvider(IHttpContextAccessor httpContextAccessor)
    {
        _httpContextAccessor = httpContextAccessor;
    }

    public CancellationToken GetCancellationToken()
    {
        return _httpContextAccessor?.HttpContext?.RequestAborted ?? CancellationToken.None;
    }

    public Task<CancellationToken> GetCancellationTokenAsync()
    {
        var cancellationToken =  _httpContextAccessor?.HttpContext?.RequestAborted ?? CancellationToken.None;
        return Task.FromResult(cancellationToken);
    }
}

参考资料