Kitex/Hertz 助力大模型:三周年重要特性回顾

科技   2024-12-13 09:02   重庆  

Kitex 项目地址|https://github.com/cloudwego/kitex

Hertz 项目地址|https://github.com/cloudwego/hertz

CloudWeGo 开源走过了三周年,秉持内外统一的原则,我们持续在开源仓库迭代,将服务于字节内部的特性发布到外部,23-24 年 Kitex/Hertz 重点关注大模型用户体验性能三个方面,帮助新的业务场景快速发展,并在用户体验和性能上持续优化。同时,Kitex/Hertz 在外部企业得到了广泛应用,也吸引了众多外部开发者,持续完善 CloudWeGo 的生态。

本文根据 CloudWeGo 三周年 《Kitex/Hertz 助力大模型:三周年重要特性回顾》 分享整理,介绍近一年来 Kitex/Hertz 的重要特性,希望为企业用户、社区同学在自己的项目中更好的应用 Kitex/Hertz 构建自己的微服务体系提供帮助。


加强流式能力助力大模型


大模型快速发展,字节跳动的 AI 应用也发展迅速,而流式通信是大模型应用的主要通信模式,为了更好的支持业务发展,我们在近一年对微服务的流式通信在稳定性、工程实践、性能上做了诸多优化。



Kitex/Hertz 过去流式能力的支持


Kitex/Hertz 均支持流式场景,Kitex 支持 gRPC,性能优于官方 gRPC,功能基本对齐;Hertz 支持 HTTP Chunked Transfer Encoding、WebSocket。
但以上能力不足以支持字节内部 LLM 快速发展,有以下几点原因:
  • 端上 SSE 应用更多
多模态模型之前,大模型应用主要是文本对话场景,多使用 SSE 协议实时返回服务端结果给客户端。文本推送场景更简单,选择浏览器支持友好的简单协议即可。
  • Thrift -> Protobuf 切换负担
虽然 RPC 场景的流式通信普遍使用 gRPC(Protobuf),Kitex 也支持 gRPC,但字节服务端服务主要使用 Thrift 定义,研发对 Thrift 使用更加熟悉,使用 gRPC 协议的服务并不多。然而,在流式使用诉求变多的情况下,我们需要结合内部实际情况,减少研发切换的心智负担;另一方面,广泛增加 Protobuf 定义的服务,对统一 IDL/接口管理并不友好。
  • 缺乏工程实践
流式通信相比 PingPong 一发一收的模型在服务治理和工程实践上增加了复杂度。业界对流式通信的支持缺乏工程实践的沉淀;流式接口很容易用错进而影响到服务的稳定性;从可观测性角度,也没有看到对流式监控的定义。

流式能力 - SSE/Thrift Steaming


> Hertz SSE

SSE(Server-Send Events) 基于 HTTP 协议,支持服务端向客户端单向推送数据,优点是简单易用、对开发者友好,适合文本传输,满足文本对话模型的基本通信需求。
相比于 WebSocket,SSE 更轻量级,对于文本对话的大模型应用,服务器只需要推送数据到客户端,无需处理双向通信的复杂性。但在语音对话模型场景,同样浏览器支持友好的 WebSocket 则更适合。
SSE 可以定义不同的事件类型,并在客户端根据事件类型处理数据。这在大模型应用中,可以用来区分不同类型的响应数据(例如,部分输出、错误消息、状态更新等)。

但是,SSE 并不适合服务端:服务端计算和传输性能要求较高,不适合采用低效的文本协议;JSON 简单但并不适合服务端复杂交互场景,强类型的 RPC 更友好;部分场景需要双向流式通信。

所以,结合字节内部的情况,我们支持了 Thrift Streaming。

> Kitex Thrift Streaming

流式通信的使用场景除了大模型应用外,还有其它业务线的使用场景,比如:抖音搜索为提升性能,希望 RPC 流式返回结果,如在视频打包阶段,根据召回的视频 ID 获取物料等相关信息,希望一次请求打包服务(10个doc),先打包完的先返回;飞书 People 数据导出场景会并发获取数据,如果等待所有数据都获取到后再填充 excel 返回,当数据量过大时会导致 OOM,进程异常退出。所以,增强流式能力,一方面助力大模型快速发展,另一方面也满足其他业务场景发展。
虽然 Kitex 支持 gRPC,但对内我们还是建议使用 Thrift,支持的多可以满足多元需求,但对一个公司内部最好是明确一种最佳实践,尽可能减少研发的选择负担,工具链体系支持也会比较友好。
Streaming 协议
字节内流量管控主要依赖 Service Mesh,但为了能快速支持落地,不依赖 Service Mesh 对新协议支持,Kitex 先支持了基于 gRPC(HTTP2) 的 Thrift Streaming。因为官方 gRPC 的协议规范支持扩展 content-type,所以实现方案是基于 gRPC 的 PRC 通信规范,将 Protobuf 编码改为 Thrift 编码
Thrift over gRPC 在 23 年 12 月开始在字节内部试用,于 24 年 3 月在 Kitex v0.9.0 正式发布,目前已经在字节内部大规模使用,使用说明见官网文档。
  • 优点:

    • Service Mesh 兼容:基于 HTTP2 传输,Service Mesh 无需单独支持

    • Kitex 支持成本低:根据 SubContentType 明确解码类型(gRPC 协议规范支持扩展)

  • 缺点:
    • 资源开销大:流控、动态窗口引入额外的开销

    • 延迟影响大:流控机制,流量大一些或发送大包会导致延迟显著劣化,需要用户自行调整 WindowSize

    • 问题排查难:复杂度也增加了问题排查难度
Thrift over gRPC 可以快速落地,但从性能和问题排查的角度,我们自研了 Streaming 协议(Streaming over TTHeader),简化流式通信,目前在内部联调和试用中,预计会在 24 年 11-12 月发布。
Thrift IDL 如何定义 Streaming
了解 Thrift 的用户清楚,原生 Apache Thrift 不支持流式接口的定义,如果新增关键字,会导致其它 Thrift 通用解析工具无法支持,包括 IDE 的插件解析。
所以通过注解的方式对 Thrift 的 RPC 方法定义流式类型,可以保证解析的兼容性:
  • streaming.mode="bidirectional":双向流式

  • streaming.mode="client":Client Streaming

  • streaming.mode="server":Server Streaming

当前支持的 Thrift Streaming over gRPC 和即将发布的 Thrift Streaming over TTHeader 都采用这个方式来定义流式方法,预期 Client 端会提供 Option 来指定采用哪一个 Streaming 协议,Server 端会通过协议探测兼容多种协议。


流式泛化调用


如果端上采用 SSE 做流式通信,服务端采用 Thrift Streaming 通信,那端上到服务端整体是如何通信的?

以内部文本对话模型的场景为例,流量在经过 API 网关后会进行协议转换,服务端采用 Server Streaming 的流式类型将数据推送给端上。

其中,比较重要的一个能力是协议转换,除此之外,压测、接口测试平台都需要动态构造数据对服务端的服务测试。
了解 Kitex 的用户清楚,Kitex 针对 Thrift 协议提供了泛化调用的能力,主要就是面向这类通用服务提供支持,原来内部微服务以 Thrift PingPong 服务为主,Kitex 提供了 Map、JSON、HTTP 数据类型的泛化调用,以及面向流量转发场景的二进制泛化。
因此,面向流式接口,Kitex 新增了流式泛化调用的支持,相对于 PingPong 的泛化接口,流式泛化需要针对三种流式类型分别提供接口。
  • PingPong/Unary 泛化调用接口
  • Streaming 泛化调用接口
目前完成支持的是更为通用的 JSON 数据类型,其它数据类型后续也会根据业务需求情况排期支持。(因为 Kitex Streaming v2 接口待发布,避免影响流式泛化的使用体验,所以该支持未正式发布,但功能已就绪,用户可以到官网泛化调用的部分看英文的试用说明)

流式能力用户体验

虽然针对流式的基本功能场景,我们做了能力完善,以上也介绍了 Kitex/Hertz 过去支持的、新支持的、即将发布的流式能力。但开发过流式接口的同学,包括使用其它框架,如官方 gRPC 框架,大家是否熟悉如何正确使用流式接口,遇到问题是否清楚如何定位?
在字节内部,因为流式服务的发展,明显感觉到反馈的问题数量变多。一方面,相对于 Thrift PingPong,在基础能力层面我们支持的还不完善;另一方面,流式接口的开发需要大家非常熟悉如何正确使用,否则很容易误用而引起问题。
因此,我们在 24 年开启了流式优化专项,针对各类问题做了梳理,逐个进行优化。在用户使用体验上,有些问题和流式的接口定义有关,综合考虑决定丢掉流式包袱,发布 Streaming v2 接口。
以下是部分存在的问题,和进行中的优化。如何正确使用流式接口,单纯从框架层面也很难做好约束,所以后续我们会发布流式接口的使用规范和最佳实践,帮助用户开发好流式接口。如果大家有更好的流式使用建议,也欢迎和我们交流~
以流式的可观测性举例,之前流式接口的监控并没有单独定义,复用 PingPong 上报,所以只有流整体的上报信息,缺失 Recv/Recv 的监控。因此,在支持 Thrift Streaming 时,新增了 StreamSend & StreamRecv 事件,框架会记录发生的时间和用户传输数据的大小。企业用户自定义的 Tracer 上报只要新增对 rpcinfo.StreamEventReporter 接口的实现,Kitex 会在每次 Recv、Send 执行完后调用该接口, 在该方法里,可以获取到本次 Recv、Send 的事件信息。如下,是一个 Stream 里 Send/Recv 的 Trace 信息。


新功能、用户体验/性能提升回顾


虽然近一年针对流式能力做了专项支持和优化,但同时,我们也提供了其它新的能力来满足用户需求、增强用户使用体验、继续提升框架性能。


新功能 - Thrift/gRPC 多 Services

gRPC 官方框架支持多 Service,但 Kitex 之前的版本并未提供多 Service 的支持,主要是为了和 Thrift 使用对齐。Thrift 的限制是因为支持多 Service 会引入协议不兼容变更,对用户有影响。在字节内部,TTHeader 协议被广泛使用,所以我们决定通过 TTHeader 传递 IDL Serivce Name,来解决 Thrift 不支持多 Service 问题。

Kitex v0.9.0 版本正式支持在一个 Server 里注册多个 IDL Service,包括 Thrift、Protobuf。Thrift 基于 TTHeader 提供了协议层面真正的多 Service 功能,同时兼容旧的 CombineService。
CombineService 这里做一个简单的介绍,之前为了解决 IDL 过大问题(会导致代码产物大、编译速度慢),Kitex 提供了伪多 Service 的功能 - Combine Service,可以让服务端将一个 IDL Service 拆分为多个 IDL Service,但要求多个 IDL Service 不能有同名的方法(协议上没有支持多 Service,无法做方法路由),最终 Kitex 会将多个 IDL Service 合并为一个 Service,所以称为 CombineService。
Kitex 新增的多 Service 支持,服务端不仅可以注册多个 IDL Service,且可以同时提供 Thrift 和 Protobuf 接口,比如,使用 Kitex-gRPC(Protobuf),但想切换到 Thrift Streaming,又想保证兼容旧接口的流量,那就可以提供两类 IDL 接口过渡。
如下是服务端注册多 Service 的示例:

新功能 -  Mixed Retry

Kitex 之前提供了两种重试功能:异常重试和 Backup Request。异常重试可以提高成功率(提升服务的 SLA),但大部分是超时重试,延迟会上涨;Backup Request 可以降低请求延迟,但如果有失败的返回,会终止重试。

在内部实践中,业务普遍反馈希望能同时具备两种重试的能力,相对前两种重试的优势:
  • 可以优化 Failure Retry 的整体重试延迟

  • 可以提高 Backup Request 的请求成功率
因此,Kitex 在 v0.11.0 版本中支持了 Mixed Retry,同时具备 Failure Retry 和 Backup Request 功能的混合重试功能。
方便理解三种重试的差异,以下给出一个场景:假设第一次请求耗时 1200ms,第二次请求耗时 900ms,配置 RPCTimeout=1000ms、MaxRetryTimes=2、BackupDelay=200ms。
三种重试的结果对比:
  • Mixed Retry: Success, cost 1100ms

  • Failure Retry: Success, cost 1900ms

  • Backup Retry: Failure, cost 1000ms


用户体验提升 - Frugal & FastCodec (Thrift)

Frugal 和 FastCodec(Thrift) 都是 Kitex 提供的高性能 Thrift 序列化工具,Frugal 相较于 FastCodec 的优势是不需要生成代码,可以大幅解决产物过大的问题。

但存在两个问题:
  1. Frugal 和 FastCodec 解码都必须依赖带头的包,如果是 Thrift Buffered 包则会 fallback 到 Apache Codec,用户需要清楚收到的协议,否则使用 Frugal 不能完全去除生成代码。
  2. Frugal 基于 JIT 实现,完成了 x86 支持,但 ARM 提供的是 fallback 策略,性能表现不佳
针对协议绑定的问题,新版本支持了 SkipDecode,测试结果 SkipDecode + FastCodc 性能依然优于 Apache Thrift Codec。
针对 Frugal ARM 问题,提供了新的反射支持,无需对不同架构提供单独的支持,虽然使用反射,但绕过反射里的类型检查来获取较高的性能,测试结果相对 JIT 没有劣化反而略优。

用户体验提升 - 产物精简和生成提速优化

产物体积大、产物生成慢、编译速度慢,在字节内部迭代较久的服务上痛点非常明显,所以 Kitex 提供了多种优化手段减少产物大小、提升产物生成速度。

> IDL 裁切

一个复杂且迭代较久的 IDL,里面有很多废弃的结构体定义,主动清理这类 IDL 里多余的定义也会增加研发负担。裁切工具支持按照 RPC 方法需要的结构体定义生成代码,用户也可以指定需要生成哪些方法,根据字节内部大仓库的试点,生成耗时减半、产物体积减少 60%+ 。
使用方式:$ kitex -module xx -thrift trim_idl xxxx.thrift
效果示例:下图的例子中,裁切工具删除了 6w 无用的结构体,53w个字段。

> no_fmt 提速

代码产物生成后,默认会对代码做 format 提高可读性,但产物代码一般用户很少会关心代码可读性,所以用户可以关闭 fmt 的选项来提升生成速度。
使用方式:$ kitex -module xx -thrift no_fmt xxxx.thrift
效果:字节内某平台生成耗时 P90 从 80s 下降到了 20s
> 删除 Kitex 无需依赖的代码
Kitex 默认会生成 Apache Thrift 的全套代码,但实际除了 Codec 部分在 Fallback 场景使用,其它代码都不需要。
所以,Kitex 在 v0.10.0 版本默认删除了 Thrift Processor,同时通过参数指定的方式可以默认去掉所有 Apache Thrift 代码。
kitex -module xxx -thrift no_default_serdes xxx.thrift
使用方式:$ kitex -module xxx -thrift no_default_serdes xxx.thrift
效果:产物体积减小约 50%+
> Frugal Slim 极致精简
使用方式:$ kitex -thrift frugal_tag,template=slim -service p.s.m idl/api.thrift,将使用 Frugal 做 Thrift 序列化
效果:产物体积减小约 90%

用户体验提升 - kitexcall

虽然 RPC 调用比 HTTP 简单便利,但测试起来却并不方便,需要用工具先生成代码,然后构造请求数据。前面提到测试平台会采用泛化调用来构造请求数据不依赖生成代码,但泛化调用的使用成本并不低,用户首先要理解泛化调用的使用和数据构造。

为了提高测试的便利性,基于 Kitex JSON 泛化调用提供了单独的命令工具 - kitexcall, 方便用户使用 JSON 数据发起 Thrift 测试。(该功能由社区同学贡献支持,这里表示感谢~ )
使用方式:$ kitexcall -idl-path echo.thrift -m echo -d '{"message": "hello"}' -e 127.0.0.1:8888
后续的优化计划:
  • 界面化,测试更便利
  • 支持 gRPC 测试
  • 无需指定 IDL,结合 server reflection 能力获取 idl 信息

性能优化 - Thrift 按需序列化

随着业务迭代 IDL 定义越来越复杂,生产中上游服务可能只需要部分字段,但需要对所有字段进行序列化和传输,引入额外的性能开销,考虑到这个问题,Kitex 对 Thrift 支持按需序列化的功能。

参考 Protobuf 提供了 Thrift FieldMask 功能,让用户选择编码字段,优化序列化和传输开销。
比如如下 Resp,只对 Foo 字段做编码返回,忽略 Bar 字段:
用户构造了 Bar 的数据,但对 Foo 字段做标注,框架则只会对 Foo 编码:

也支持由对端来指定需要的字段,具体使用方式见官网按需序列化的使用文档。


性能优化 - Thrift 内存分配优化

Kitex 持续关注 RPC 场景的性能表现,在成本压力较大的当下,我们在深度探索更多的优化,热路径上的常规优化都做了,现在继续优化其实就没那么常规了,v0.10.0 发布了新的优化,主要关注内存分配和 GC

  • Span Cache:优化 String/Binary 解码开销:

    • 预分配内存,减少 mallocgc 调用

    • 减少实际生成的对象数量 -> 减少GC 开销

  • 容器字段集中分配内存
    • 同理,原本对每个元素单独分配内存改为集中分配
Span Cache 可以优化 CPU,但会带来内存的上升,避免对内存规格小的服务造成影响,并没有默认开启,需要用户主动配置开启:

优化效果:极限测试下吞吐提升约10%,延迟降低约30%。


内存分析工具

RPC/HTTP 的接收对象由框架构造、分配内存、赋值返回给用户,但用户代码里如果一直持有对象就会内存泄漏,而pprof heap 只能告诉你哪里分配了,无法告诉你哪里引用了 ,那怎么知道 Go 对象到底被谁引用了呢?
其实,GC 扫描对象做标记,可以拿到引用关系,再结合变量名和类型信息,就可以分析对象的引用情况。我们基于 Delve 支持了 goref 对象引用分析工具,并在 7月开源(github.com/cloudwego/goref),解决了 Go 原生工具无法分析内存引用的问题,帮助 Go 研发快速发现内存泄漏问题,完善 Go 工具生态。

举一个例子,下图是 pprof 的 Heap Profile,可以看到当前被引用的对象主要在 FastRead(Kitex 的反序列化代码) 里分配的内存,而解码分配内存构造数据是正常的情况,这个火焰图对排查问题没有太大帮助,内存分配的地方通常不是导致内存泄漏的地方

但使用 goref 工具,就能看到如下结果,mockCache 持有了 RPC 的 Resp 导致内存未被释放,问题一目了然。


总结和展望



总结

加强流式能力助力大模型

  • Kitex/Hertz 提供的流式能力:gRPC、HTTP 1.1 Chunked、 WebSocket,、SSE、Thrift Streaming

  • SSE <-> Thrift Streaming

  • Streaming 泛化调用

  • Streaming 能力的优化,提升用户体验、完善工程实践
新功能、用户体验/性能提升回顾
  • 新功能: Thrift/gRPC 多 Service、Mixed Retry

  • 用户体验: Frugal/FastCodec、产物精简和生成提速优化、 kitexcall

  • 性能优化: Thrift 按需序列化、内存分配优化

  • 内存分析工具: goref


展望

未来一年我们会继续增强流式能力并优化流式用户体验,对流式接口的使用给出使用规范,帮助用户更好的开发自己的流式服务:

  • 发布 Kitex Streaming v2 接口,解决历史问题

  • 发布 TTHeader Streaming 提升性能

  • 工程实践:优雅退出、重试、超时控制

  • 发布流式相关规范:错误规范、接口使用规范
除此之外,也会考虑加强流式的生态能力,如丰富流式的泛化调用,对网关场景提供更友好的支持:
  • SSE <-> Thrift Streaming(HTTP2 and TTHeader Streaming)

  • WebSocket <-> Thrift Streaming (HTTP2 and TTHeader Streaming)

  • Streaming 的二进制、Map 泛化调用

特别预告,Kitex 计划在接下来的中版本逐步去除 Apache Thrift 生成代码,因为 Apache Thrift v0.14 接口的不兼容变更迫使 Kitex 绑定 Apache Thrift v0.13,为解决该问题,Kitex 会去除 Apache Thrift 的依赖。



三周年演讲 PPT 下载链接:https://github.com/cloudwego/community/tree/main/meetup/2024-09-21


点击底部【阅读原文】可直达⬇️


项目地址
GitHub:https://github.com/cloudwego
官网:www.cloudwego.io

字节跳动技术团队
字节跳动的技术实践分享
 最新文章