普通视图

发现新文章,点击刷新页面。
昨天以前掘金专栏-字节跳动技术团队

Simple Editor - 高效智能地设计动效

一、概述

在当今数字化时代,动效设计已成为用户体验设计中不可或缺的一环。它不仅显著提升了用户体验,增强了用户粘性,还为业务带来了可观的收益。一个好的动效自然离不开专业且实用的动效设计工具,本文将为大家介绍一款我们自研的动效编辑器,旨在为动效设计师以及相关业务合作的同学提供一个高效、便捷且功能强大的创作平台,让设计师可以高效智能地设计动效。

图片

二、问题分析

动效设计流程

目前 Lottie 动画作为常见的动效资产格式,广泛应用于体验交互和活动设计中。通常这类动效资产的生产流程是,设计师将 Figma/Illustrator/Photoshop 之类图形软件中的图像资源导入到 Adobe After Effects 工具中进行动效制作,再通过插件 Bodymovin 进行 Lottie 动画的渲染输出。

图片

Lottie 存在的问题

Lottie 提供了基础的动画渲染能力,但在实际应用中仍存在诸多问题,主要表现是在 AE 中设计的效果往往和 lottie 运行时效果不一致。

  1. 效果存在差异:  AE 中许多效果(渐变、遮罩等)在 lottie 上渲染不一致
  2. 功能存在缺陷:  不支持 3d 图层,卡片的透视效果无法渲染
  3. 平台渲染差异:  在客户端和 web 上使用不同的方式渲染,动画效果不一致
  4. 资产管理低效:  通过文档文件形式交付,存在资产复用、管理等问题

市场调研

既然 Lottie 存在着这些问题,那么大家都是如何来解决的呢?

公司内外存在各式各样的设计工具,我们筛选出了 3 个主要用于动效制作的工具,从多个维度与 AE 进行了比较。

图片

通过比较,我们发现,Rive 和 Galacean Effect 均解决了动效渲染不一致的问题,尤其 Rive,凭借强大的状态机和事件交互能力,能够创作出众多富有趣味性和可玩性的动效。同时 Galacean 也有其特色的地方,其支持了简单的 AI 动效的能力,为设计动效降低了门槛。

那么我们能否直接使用 Rive 和 Galacean Effect 来制作业务中的动效呢?答案是否定的,主要原因是:

  1. 无法私有化部署,存在数据安全的风险
  2. 企业版授权费用高昂,采购审批未通过
  3. 资产格式无法与lynx兼容,难以在公司内项目广泛推广使用

自研动效编辑器

除此之外,设计师在日常工作中也积累了许多提升效率的想法,例如通过 AI 生成关键帧、将过往的动效设计保存为模板以便复用等。然而,使用第三方设计工具往往无法支持这些定制化的提效需求,导致设计师的想法方案难以落地。

基于以上痛点和挑战,同时我们团队有自研的高性能渲染引擎 Simple Engine(具有优异的渲染性能、支持 h5、lynx 的 webgl 跨端一致性渲染),自研一款动效编辑器成为可能。自研编辑器不仅可以保证设计阶段和线上效果一致,解决现有工具的局限性,还能为设计师和开发者提供更高效、更灵活的动效设计与交付解决方案。

下面是使用 SimpleEditor 制作动画的一个案例,其中用到了 AI 生成动效的功能,同样的动效从开始设计到完成交付,和 AE 比,耗时从 0.5d 左右 降低到 10min 左右。

三、技术架构

研发编辑器存在着很多技术难点,主要集中在高效的数据管理、流畅的交互控制、复杂的图形变换算法、解耦的撤销回退机制、图层关键帧同步、贝塞尔曲线应用及状态机等。

本文仅介绍编辑器的技术架构,后续会逐步发布详细的技术方案相关的文章,敬请期待。

图片

四、核心功能

在介绍功能之前,让我们先一同了解一下本编辑器的整体布局。

我们借鉴了 Rive 编辑器的排版。顶部为功能区,其中包含一些常用操作;中间为图层节点树、资源、画布、属性及配置区域;底部为状态机、时间线、曲线编辑、曲线快捷设置等。

图片

关键帧预设

在动效设计领域,关键帧的设置往往是创作过程中最需要精雕细琢的环节——它不仅依赖设计师对节奏和细节的敏锐把控,还需耗费大量时间逐帧调整。即便是经验丰富的设计师,面对新项目时也不得不从零开始反复调试,导致效率瓶颈和创意重复。为此,我们推出了关键帧预设功能,这一能力不仅大幅缩短了基础动画的搭建周期,更将设计师从重复劳动中解放,使其能够专注于创意表达与效果创新,真正实现“效率与灵感并重”的设计体验。

1.AI 关键帧预设

我们采用了提示工程方案,通过设计、优化和管理输入提示(prompt),引导大语言模型(LLM)生成符合需求的动画数据,然后转化为关键帧,方便设计师再次对动画精调。

图片

2.通用关键帧预设

通过对动效设计师日常工作中的常用设计进行梳理和提炼,我们将这些高频使用的动效效果整合为通用预设库,开放给所有设计师直接调用。这一功能不仅减少了重复设计的繁琐,还能帮助设计师快速实现高质量的动效效果,显著提升工作效率,同时确保设计风格的一致性。无论是新手还是资深设计师,都能从中受益,轻松打造出符合预期的动效作品。

图片

状态机

前文已介绍,Rive 最显著的优势在于支持强大的状态机功能,能够使设计同学在设计阶段实现动画衔接的状态切换。 至此,我们已实现状态机的能力,支持以可视化方式连接动画,并可定义驱动状态转换的逻辑。这为设计团队和研发团队创建了一种全新的协作模式,使两个团队在开发过程中能够进行深度迭代,而无需进行复杂的对接工作。

1.状态切换

现在先来看个例子,假设需要实现如下动画:

首先让素材从各个方向飞入画布,随后循环播放素材漂浮的动画。循环播放一段时间后,再让其中某个素材图层消失。在通常情况下,应如何实现这样的动画? 从下面的流程图中可以发现,动画的控制是需要开发程序控制。

图片

然而,引入状态机后情况全然改变。在设计方面,仅需导出一个动画;研发获取动画资源后可直接播放,无需再手写代码逻辑来实现设计所预想的动画逻辑,减少了对接的成本。 下面是在编辑器中实现的效果

图片

2.事件交互

动画通常需添加事件交互,才能展现其灵魂。一般而言,事件绑定与处理需研发人员在工程代码中完成,此过程中需频繁与动效设计师沟通交互细节及切换逻辑。若在状态机切换中融入事件交互功能,则可降低这部分对接成本。

我们参考了 rive 的状态机实现方式,引入了变量输入与事件监听功能。设计师能够定义多个变量和监听器,可以将监听器绑定至图层。当点击图层或进行其他操作触发监听器时,将配置在监听器上的变量值进行派发。而状态机的过渡则定义了与变量匹配的规则,当过渡条件与变量值相匹配时,便会触发当前过渡的切换。以下呈现了一个经典的具备交互功能的状态机示例。

图片

Figma 导入

动效设计的前期工作通常依赖于视觉设计输出的视觉图,设计师需要根据这些视觉图来完成动效制作。

在现代设计流程中,Figma 已成为视觉设计的主流工具,但其导出功能仅支持单张图片,动效设计师不得不将每张图片逐一导出,再导入到 After Effects 或其他动效工具中。不仅如此,设计师还需手动将这些图片重新定位到视觉设计中的原始位置,这一过程耗费了大量时间和精力。

为了解决这一问题并提升效率,我们开发了 Figma 导入功能,直接省去了动效制作中最繁琐的前期准备工作,显著提升了设计师的工作效率。

除此之外,设计师还需要经常替换动效资源,但是动效关键帧保持不变,为此我们也提供了 figma 的资源替换能力,对于换肤场景的动效制作可以快速完成。

3D layer

在动效设计中,想要实现2D图形的3D旋转透视效果,传统做法是先用 AE 制作 3D 动画并导出透明视频或序列帧,但这种方法会导致资源体积过大、内存消耗高、动画无法灵活控制且维护困难。为此我们支持了,直接在编辑器中设计3D图层动画,通过轻量化的运行时实现实时播放,既保持视觉效果又解决了传统流程的痛点。

图片

导出 CSS 动画

Lottie 是基于 JSON 格式的动画,需在 js 层进行解析和渲染(通常采用 canvas 或 svg 方式)。在低端设备上,由于 CPU/GPU 性能欠佳,运行 Lottie 动画会占用大量内存,且计算和渲染主要在主线程执行,会占用有限的线程资源,进而导致设备发热和卡顿,影响交互体验。为解决低端设备的相关问,大型活动通常需准备降级方案,而 CSS 动画成为首选方案。

CSS 动画一般由研发人员依据 lottie 动画手动编写 CSS 代码进行还原。此过程不仅耗费人力,还容易出现错误,并且很难百分之百还原动画效果。若设计方需反复修改动画,还会投入更多的研发人力。为解决这一问题,我们实现了单个 timeline 动画导出 CSS 的功能,基本能够做到百分之百还原动画效果,节省了设计与研发协作沟通的成本以及研发人员手动实现 CSS 动画的人力投入。

下方左侧展示的是编辑器中的 3D 轮盘动画,右侧为导出的 CSS 动画,可见这两个动画完全相同。除 3D 图层动画外,我们还支持将包含预合成的动画导出为 CSS,以最大程度拓展 CSS 动画导出的功能范围。

图片

五、未来规划

动画编排

在动效设计中,设计师常需将多种格式的素材(如Lottie动画、Spine骨骼动画、序列帧动画等)整合到一个动画流程中。这种混合设计的关键在于通过编辑器对各类资产进行动态编排与状态控制。若缺乏状态机或脚本支持,单纯在编辑器中堆叠素材难以实现逻辑连贯的交互效果;而借助状态机,可精准调度不同格式素材的触发时机与切换逻辑。

例如下方农场播撒种子的动效:

在「立即播放」按钮的场景中,按钮的初始状态为帧动画(适用于简单循环动效),点击后触发按钮退场动画,随后播撒种子的 Spine 骨骼动画启动(适合复杂角色动画),最终按钮重新入场。整个过程通过状态机划分阶段,实现多格式资产的无缝衔接,与动效交付中“有序+无序组合”的设计思路一致。这种方案既保留了各格式的技术优势(如Lottie的小文件体积、序列帧的高还原度),又通过状态逻辑整合提升了整体动效的可控性与用户体验。

多人协作

在数字化协作工具快速迭代的当下,以Figma、Rive为代表的云端设计编辑器正通过实时多人协同功能重塑创作流程。与传统单机设计软件(如Adobe After Effects)的"文件孤岛"模式相比,这类工具允许团队成员以"多光标并行编辑"的形式介入同一份设计文件——产品经理可在画布旁标注交互逻辑,UI设计师调整组件样式的同时,开发工程师已通过内置代码面板提取参数,甚至客户也能通过链接直接插入反馈批注。

技术层面,这类编辑器依赖OT算法(Operational Transformation)或CRDT(Conflict-Free Replicated Data Type)实现毫秒级操作同步,确保即便百人同时操作也不会出现图层覆盖或数据丢失。例如Figma采用动态增量更新机制,用户每处笔触变化都会以差分数据包广播至协作者终端,配合分层版本树(Version Tree)自动归并冲突修改。这种设计让团队如同置身虚拟工作室,既能在设计稿中看到协作者的实时光标轨迹与头像标识,又能通过时间旅行(Time Travel)功能回溯任意历史版本,彻底告别"final_final_v3.psd"式的版本混乱。

资产交付和管理

目前的资产交付主要通过飞书文档的形式,流程相对繁琐,效率较低。未来,我们计划对资产交付和管理进行优化,构建一个统一的资产交付平台。设计师可以在该平台上方便地发布、管理和分享动效资产。同时,平台将提供详细的资产信息和版本管理功能,确保团队成员能够清晰了解资产的使用情况和更新记录。此外,平台还将支持与研发系统的对接,实现资产的快速更新,减少交付过程中的人为错误和时间成本。

其他规划

图片

六、团队介绍

我们是「抖音前端架构-互动体验技术」团队,主要为字节跳动业务提供互动技术解决方案。

技术产品包含面向互动 / 小游戏研发场景的 SAR Creator、高效智能的动效设计编辑器 Simple Editor、互动场景端能力套件 AnnieX 互动容器

在这些技术建设与业务落地上,和 TikTok 创意设计团队、抖音直播多媒体动效团队一同推进,不断探索字节跳动应用生态下的创新业务形态。

btrace 3.0 重磅新增 iOS 支持!免插桩原理大揭秘!

重磅更新

btrace 是由字节跳动抖音基础技术团队自主研发的面向移动端的性能数据采集工具,它能够高效的助力移动端应用采集性能 Trace 数据,深入剖析代码的运行状况,进而辅助优化提升移动端应用的性能体验,提升业务价值。此次更新,我们重磅推出 btrace 3.0 版本,提出了业界首创的同步抓栈的 Trace 采集方案,实现了高性能的 Trace 采集。此外,新版本在支持 Android 系统的基础上,新增了对 iOS 系统的全面支持。

欢迎访问 github.com/bytedance/b… 进一步了解。 此外,后文我们还对 btrace 3.0 诞生的背景、建设思路和实现细节进行了深入介绍,欢迎进一步阅读。

背景说明

自 btrace 2.0 正式发布以来,已近两年时间。在此期间,我们收到了大量用户反馈,经总结,主要问题如下:

  1. 接入维护成本较高:接入、使用及维护的成本均偏高,对用户的使用体验产生了影响。接入的插件配置较为复杂,编译期插桩致使构建耗时增加,接入后字节码插桩异常会导致无法正常编译,且问题原因难以排查。
  2. 系统方法信息缺失:编译期的字节码插桩方案仅能对打包至 apk 内的方法生效,无法对 android framework 等系统方法生效,导致所采集的 Trace 信息不够丰富,影响后续的性能分析。

除 android 端团队针对 2.0 版本给出的反馈外,随着行业内双端合作愈发紧密,业界对于 iOS 相关能力的需求也十分迫切。然而,苹果官方所提供的 Trace 方案 Time profiler 存在以下局限:

  1. 使用成本 :Time profiler 的界面比较复杂,配套的说明文档数量较少,使用 Time profiler 定位问题时需要消耗很多精力。
  2. 应用 灵活性 :Time profiler 工具是一个黑盒,出现问题时无法排查,且无法自定义数据维度和数据展示方式。

对此,为了持续提升用户使用体验、增强 Trace 信息丰富度、进一步降低性能损耗,以及对 iOS 端能力的支持,我们开启了全新的 Trace 方案的探索。

思路介绍

实际上不难看出,btrace 目前存在的问题主要是由其使用的编译期字节码插桩方案引起的,因此为了解决目前存在的问题,我们重点探索了编译期插桩之外的 Trace 采集方案。

目前业界主流的 Trace 采集方案分为两种:代码插桩方案和采样抓栈方案。那么采样抓栈方案是否就是更合适的方案呢,我们对两种方案的优缺点做了简单的总结:

image.png

从上述对比可以看出,两种方案各有优劣。采样抓栈方案可以追踪到系统方法的执行、可以动态开启或关闭、接入和维护成本也相对较低,但是它在 Trace 精度和性能上存在明显的不足,核心原因在于采样抓栈采用的是定期的异步抓栈流程。首先是每次抓栈都需要经历挂起线程、回溯方法栈、恢复线程三个阶段,这三个阶段都有着明显的性能损耗。其次后台线程的定时任务由于线程调度的原因,无法做到精准的调度,以及线程挂起时机的不确定性,导致抓栈的间隔至少都设置在 10 毫秒以上,trace 的精度无法保证。

既然两种方案各有优劣,我们能否取长补短,将两种方案的优势融合呢?答案是肯定的,将异步抓栈改成直接在目标线程进行同步抓栈来免去线程挂起和恢复带来的性能损耗,再通过动态插桩提供的插桩点作为同步抓栈的驱动时机,最终就形成了 btrace 3.0 所采用的动态插桩和同步抓栈结合的 Trace 采集新方案。

image.png

新方案将如何保证 Trace 的精度呢?这对应着动态插桩的插桩点选取策略,也即寻找同步抓栈的最佳时机。实际上只要保证插桩点在线程运行时能够被高频地执行到,我们就可以通过高频地同步抓栈来保证 Trace 的精度。另外还需要注意的是,插桩点的选取不仅要保证能够被高频地执行到,同时也要尽可能的分布在“叶子节点”所处的方法上。如下图所示,如果“叶子节点”方法上没有插桩点,那么最终生成的 Trace 就会存在信息丢失。如何实现快速同步抓栈、如何进行动态插桩以及具体选取哪些插桩点将在方案明细中介绍。

image.png

值得一提的是,同步抓栈方案除了免去线程挂起和恢复的性能损耗以外,还可以在抓栈时记录当前线程的更多上下文信息,更进一步地结合了插桩与抓栈的双重优势,这方面也将在下文的方案中进行阐述。

然而,同步抓栈高度依赖桩点。若遇到极端情况,如方法逻辑本身不存在合适的桩点,或者线程被阻塞,便无法采集到相应的 Trace 信息。针对此问题,可通过异步抓栈进一步提高 Trace 的丰富度。特别是 iOS 系统,其自身具备极高的异步抓栈性能,适合在同步抓栈的基础上叠加异步抓栈功能,以进一步提升 Trace 的丰富度。

image.png

方案明细

接下来,我们将对双端的技术细节展开深入探讨。鉴于系统差异,双端的实现原理存在区别,以下将分别进行介绍。

Android

Android 端的 Trace 采集方案主要分为同步抓栈和动态插桩两部分,其中同步抓栈部分由于已经免去了线程挂起和恢复流程,所以只需要聚焦于如何实现快速的方法抓栈即可。

快速抓栈

Android Framework 本身提供的抓栈方式 Thread.getStackTrace 会在抓栈的同时解析方法符号,解析符号是比较耗时的操作。针对 Trace 采集需要高频率抓栈的场景,每次抓栈都解析方法符号显然是比较浪费性能的选择,尤其是多次抓栈里很可能会存在很多重复的方法符号解析。为了优化抓栈性能,我们选择在抓栈时仅保存方法的指针信息,待抓栈完成后,进行 Trace 数据上报时对指针去重后进行批量符号化,这样可以最大化节省解析符号的成本。

具体该如何实现快速抓栈且仅保存方法指针信息呢?在 Android 中 Java 方法的栈回溯主要依赖 ART 虚拟机内部的 StackVisitor 类来实现的,大部分的快速抓栈方案都是围绕着创建 StackVisitor 的实现类对象并调用其 WalkStack() 方法进行回溯来实现的,我们也是使用这种方案。只有少数方案如 Facebook 的 profilo 是不依赖系统 StackVisitor 类自己实现的栈回溯,但是这种方案的兼容性较差且有较高的维护成本,目前随着 Android 版本的不断更新,profilo 官方已无力再对新版本进行更新适配了。

不过在使用系统的 StackVisitor 类进行栈回溯时我们也做了一些额外的版本适配方案的优化。具体来说是构造 StackVisitor 对象的方案优化,我们不假定 StackVisitor 类内成员变量的内存布局,而是定义了一个内存足够的 mSpaceHolder 字段来容纳 StackVisitor 对象的所有成员变量。

class StackVisitor {
...
    [[maybe_unused]] virtual bool VisitFrame();
    
    // preserve for real StackVisitor's fields space
    [[maybe_unused]] char mSpaceHolder[2048]; 
...
};

然后交由 ART 提供的构造函数来妥善初始化 StackVisitor 对象,自动将预留的 mSpaceHolder 初始化成预期的数据,省去了对每个版本的对象内存布局适配工作。同时再将 StackVisitor 对象的虚函数表替换,最后实现类似继承自 StackVisitor 的效果。

bool StackVisitor::innerVisitOnce(JavaStack &stack, void *thread, uint64_t *outTime,
                                  uint64_t *outCpuTime) {
    StackVisitor visitor(stack);

    void *vptr = *reinterpret_cast<void **>(&visitor);
    // art::Context::Create()
    auto *context = sCreateContextCall();
    // art::StackVisitor::StackVisitor(art::Thread*, art::Context*, art::StackVisitor::StackWalkKind, bool)
    sConstructCall(reinterpret_cast<void *>(&visitor), thread, context, StackWalkKind::kIncludeInlinedFrames, false);
    *reinterpret_cast<void **>(&visitor) = vptr;
    // void art::StackVisitor::WalkStack<(art::StackVisitor::CountTransitions)0>(bool)
    visitor.walk();
}

最后当调用 StackVisitor.walk 后,相关回调都将分发到我们自己的 VisitFrame,这样只需要再调用相关函数进行堆栈数据读取即可。

[[maybe_unused]] bool StackVisitor::VisitFrame() {
    // art::StackVisitor::GetMethod() const
    auto *method = sGetMethodCall(reinterpret_cast<void *>(this));
    mStack.mStackMethods[mCurIndex] = uint64_t(method);
    mCurIndex++;
    return true;
}

这种方案在性能和兼容性方面能同时得到保障,维护成本也低。

动态插桩

现在可以实现快速抓栈了,那么应该在什么时候抓栈呢?这就轮到动态插桩出场了,所谓的动态插桩是利用运行时的 Hook 工具对系统内部的方法进行 Hook 并插入同步抓栈的逻辑,目前主要使用到的 Hook 工具为 ShadowHook

按照前文思路分析,对于动态插桩重点是高频且尽可能分布在“叶子节点”方法上。而在 Android 应用中 Java 对象的创建是虚拟机运行过程中除方法调用外最高频的操作,并且对象创建时的内存分配不会调用任何业务逻辑,是整个方法执行的末端。于是,第一个理想的抓栈时机即是 Java 对象创建时的内存分配。

Java 对象创建监控的核心是向虚拟机注册对象创建的监听器,这个能力虚拟机的 Heap 类已经提供有注册接口,但是通过该接口注册首先会暂停所有 Java 线程,这会存在很高的 ANR 风险,为此我们借鉴了公司内部开发的 Java 对象创建方案,实现了实时的 Java 对象创建监控能力,具体实现原理请移步查看仓库源码,这里不作详细介绍。

注册完 AllocationListener 后将在每次对象分配时收到回调:

class MyAllocationListener : AllocationListener {
    ...
    void ObjectAllocated(void *self, void **obj, size_t byte_count) override {
        // TODO 这里抓栈
    }
};

因为对象分配十分高频,如果每次都进行抓栈会有很大的性能损耗。一方面大批量的抓栈开销累计会有很大的性能成本,另一方面如此存储大规模的抓栈数据也是棘手的问题。

为控制抓栈数量以减少性能损耗,首先可考虑的方法是控频:通过对比连续两次内存回调的间隔时间,仅当该时间间隔大于阈值时才再次进行抓栈操作。

thread_local uint64_t lastNano = 0;

bool SamplingCollector::request(SamplingType type, void *self, bool force, bool captureAtEnd, uint64_t beginNano, uint64_t beginCpuNano, std::function<void(SamplingRecord&)> fn) {
    auto currentNano = rheatrace::current_time_nanos();
    if (force || currentNano - lastNano > threadCaptureInterval) {
        lastNano = currentNano;
        ...
        if (StackVisitor::visitOnce(r.mStack, self)) {
            collector->write(r);
            return true;
        }
    }
    return false;
}

除了内存分配以外,还可以通过丰富其他抓栈的时机来提升两次抓栈的时间密度,提升数据的效果。比如 JNI 方法调用、获取锁、Object.wait、Unsafe.park 等节点。这些叶子节点可以主要分为两大类:高频执行阻塞执行

高频执行是很好的进行主动抓栈的时间点,记录下当前执行的堆栈即可,比如前面介绍的内存分配、JNI 调用等。

阻塞执行即可能处于阻塞的状态等待满足条件后继续执行,对于此类节点,除了对应的执行堆栈外,还预期记录当前方法阻塞的耗时。可以在方法即将执行时记录开始执行时间,在方法结束时进行抓栈并记录结束时间。

这里以获取锁的的场景为例,获取锁最终会走到 Native 层的 MonitorEnter,可以通过 shadowhook 来代理该函数的执行:

void Monitor_Lock(void* monitor, void* threadSelf) {
    SHADOWHOOK_STACK_SCOPE();
    rheatrace::ScopeSampling a(rheatrace::stack::SamplingType::kMonitor, threadSelf);
    SHADOWHOOK_CALL_PREV(Monitor_Lock, monitor, threadSelf);
}

class ScopeSampling {
private:
    uint64_t beginNano_;
    uint64_t beginCpuNano_;
public:
    ScopeSampling(SamplingType type, void *self = nullptr, bool force = false) : type_(type), self_(self), force_(force) {
        beginNano_ = rheatrace::current_time_nanos();
        beginCpuNano_ = rheatrace::thread_cpu_time_nanos();
    }

    ~ScopeSampling() {
        SamplingCollector::request(type_, self_, force_, true, beginNano_, beginCpuNano_);
    }
};

通过封装的 ScopeSampling 对象,可以在 Monitor_Lock 函数执行时记录方法的开始时间,待方法结束时记录结束时间的同时并进行抓栈。这样整个锁冲突的堆栈以及获取锁的耗时都会被完整的记录下来。

除了锁冲突以外,像 Object.wait、Unsafe.park、GC 等阻塞类型的耗时,都可以通过这样的方法同时记录执行堆栈与耗时信息。

至此 Android 端的核心原理基本完成介绍,欢迎移步 github.com/bytedance/b… 进一步了解。

iOS

下面来看 iOS 的原理,正如前文所述,iOS 具备高性能的异步抓栈方案,因此 iOS 端采用同步与异步结合采样的 Trace 采集方案:

  • 同步抓栈:选定一批方法进行 hook,当这些方法被执行时,同步采集当前线程的 Trace 数据。
  • 异步抓栈:当线程超过一定时间未被采集数据时,由独立的采样线程暂停线程,异步采集 Trace 数据然后恢复线程,以确保数据的时间连续性。

同步采集

同步采集模式在性能和数据丰富度方面都有一定优势。在同步采集模式下,我们遇到并解决了一些有挑战的问题:

  • 选择同步采集点
  • 减少存储占用
  • 多线程数据写入性能
选择同步采集点

可能的选取方法有:

  • 依靠 RD 同学的经验,但是存在不可持续性。
  • 通过代码插桩,但是需要重新编译 app,其次无法获取到系统库方法,容易存在遗漏。

我们推荐检查 iOS app 高频系统调用,因为系统调用足够底层且对于 app 来说必不可少。可以在模拟器上运行 app,然后使用如下命令查看 app 运行时的系统调用使用情况:

# 关闭sip
# 终端登录root账号
dtruss -c -p pid # -c表示统计系统调用的次数;

通过实际分析可知, iOS app 高频系统调用可以大致分为这几类:内存分配、I/O、锁、时间戳读取,如下所示:

名称                          次数
ulock_wake                   15346  # unfair lock, unlock
ulock_wait2                  15283  # unfair lock, lock
pread                        10758  # io
psynch_cvwait                10360  # pthread条件变量,等待
read                         9963   # io
fcntl                        8403   # io
mprotect                     8247   # memory
mmap                         8225   # memory
gettimeofday                 7531   # 时间戳
psynch_cvsignal              6862   # pthread条件变量,发送信号
writev                       6048   # io
read_nocancel                4892   # io
fstat64                      4817   # io
pwrite                       3646   # io
write_nocancel               3446   # io
close                        2850   # io
getsockopt                   2818   # 网络
stat64                       2811   # io
pselect                      2457   # io多路复用
psynch_mutexwait             1923   # mutex, lock
psynch_mutexdrop             1918   # mutex, unlock
psynch_cvbroad               1826   # pthread条件变量,发送广播信号
减小存储占用

所采集的 Trace 数据中占用存储空间(内存和磁盘)最大的就是调用栈数据。调用栈具有时间和空间相似性,我们可以利用这些相似性来大幅压缩调用栈所占用的空间。

空间相似性: 可以观察到,调用栈中越靠近上层的方法越容易是相同的,比如主线程的调用栈都以 main 方法开始。因此,我们可以将不同调用栈中相同层级的同名方法只存储一份来节省存储空间。我们设计了 CallstackTable 结构来存储调用栈:

class CallstackTable
{
public:
    struct Node
    {
        uint64_t parent;
        uint64_t address;
    };
    
    struct NodeHash {
        size_t operator()(const Node* node) const {
            size_t h = std::hash<uint64_t>{}(node->parent);
            h ^= std::hash<uint64_t>{}(node->address);
            return h;
        }
    };
    
    struct NodeEqual
    {
        bool operator()(const Node* node1, const Node* node2) const noexcept
        {
            bool result = (node1->parent == node2->parent) && (node1->address == node2->address);
            return result;
        }
    };

    using CallStackSet = hash_set<Node *, NodeHash, NodeEqual>;
private:
    CallStackSet stack_set_;
};

以下图为例介绍如何高效存储调用栈。

image.png

  1. 样本 1 的A方法没有父方法,因此存储为 Node(0, A),并记录 Node 的地址为 NodeA
  2. 样本 1 的B方法的父方法为A,因此存储为 Node(NodeA, B),并记录 Node 的地址为 NodeB
  3. 样本 1 的C方法的父方法为B,因此存储为 Node(NodeB, C),并记录 Node 的地址为 NodeC
  4. 样本 2 的A方法对应的 Node 为 Node(0, A),已经存储过,不再重复存储
  5. 样本 2 的B方法和C方法同理不再重复存储
  6. 样本 3 的A方法不再重复存储
  7. 样本 3 的E方法存储为 Node(NodeA, E)
  8. 样本 3 的C方法存储为 Node(NodeE, C)

时间相似性: App 中很多方法的执行时间都远大于我们的采集间隔,因此连续一段时间内采集到的调用栈很有可能是相同的。我们可以把相邻相同调用栈的记录进行合并,只存储开始和结束两条记录,就可以极大程度上减少存储空间占用。

image.png

以上图为例进行说明,如果以 1ms 的间隔进行数据采集,而 A->B->C->D 栈执行了 100ms,那么就会采集到 100 条记录,由于这 100 条记录对应的是相同的栈,我们可以只记录开始和结束两条记录,从而大幅压缩存储占用。

多线程数据写入

同步采集方案中,存在多线程同时写入数据到 buffer 的情况,必须对多线程写入进行处理,保证数据不异常。

一般通过加锁来保证数据安全,但是性能比较差。而 CAS 操作可以显著优于加锁,但是 lock-free 不等于 wait-free,也会有偶发性性能问题。还可以使用 Thread Local Buffer 的方案,其最大的优势是完全避免线程间冲突性能最优,但是容易造成内存浪费,需要仔细考虑内存复用机制。我们摒弃了以上传统方案,而是将单个 buffer 拆分为多个 sub buffer,将线程的每次写入操作根据线程 ID 和写入大小分配到各个 sub buffer,类似哈希机制来提升写入的并发度。

image.png

异步采集

异步抓栈方案相对成熟,Apple 官方的 Time Profiler 以及开源的 ETTrace 等项目均采用了该方案。抖音 iOS btrace 初版同样运用了异步抓栈方案。异步抓栈方案不可避免地需要先暂停线程执行,接着采集数据,最后恢复线程执行。此过程易出现死锁与性能问题,我们也对此进行了针对性处理。

防止死锁

当访问某些系统方法时,采集线程与被采集线程可能同时持有相同的锁。例如,在同时调用 malloc 进行内存分配时,可能会触发 malloc 内部的锁。当代码以如下方式被触发时,将会导致死锁:

  1. 被采样线程调用 malloc 并首先获取锁;
  2. 被采样线程被采样线程暂停执行;
  3. 采样线程调用 malloc 并尝试获取锁,但是永远等不到锁释放。

最终,采样线程会陷入永久性的等待锁状态,被采样线程则会发生永久性暂停。

image.png

针对此类问题也没有特别好的处理办法,目前是通过一些约定禁止在采样线程在挂起被采样线程期间调用危险 api,类似于信号安全函数,具体已知的函数如下:

  • ObjC 方法,因为 OC 方法动态派发时可能会持有内部锁
  • 打印文本(printf 家族,NSlog 等)
  • 堆内存分配(malloc 等)
  • pthread 部分 api
性能优化

过滤非活跃线程: 抖音这种大型 App 在运行过程中会创建数量非常多的线程,如果每次采样都采集所有线程的数据,性能开销是无法接受的。同时,也没有必要每次都采集所有线程的数据,因为绝大多数线程在大部分时间里都在休眠,只有当事件发生时线程才会唤醒并处理执行任务,因此可以只采集活跃状态的线程的数据,同时,对长时间未被采集数据的线程强制采集一次数据。

bool active(uint64_t thread_id) 
{
    mach_msg_type_number_t thread_info_count = THREAD_BASIC_INFO_COUNT;
    kern_return_t res = thread_info(thread_id, THREAD_BASIC_INFO, reinterpret_cast<thread_info_t>(info), &thread_info_count);
    if (unlikely((res != KERN_SUCCESS)))
    {
        return false;
    }

    return (info.run_state == TH_STATE_RUNNING && (info.flags & TH_FLAGS_IDLE) == 0);
}

高效栈回溯: 异步回溯线程的调用栈时,为了防止读取到非法指针中的数据导致 app 崩溃,通常会使用系统库提供的 api vm_read_overwrite来读取数据,使用该 api 读取到非法指针的数据时会得到一个错误标识而不会导致 app 崩溃。虽然vm_read_overwrite已经足够高效(耗时在微秒级别),但其耗时相比于直接读指针的耗时仍然高了数十倍。而且 app 的调用栈通常都有数十层,因此vm_read_overwrite的高耗时问题会被放大。我们在回溯调用栈之前已经将线程暂停了,理论上线程的调用栈不会发生变化,所有的指针都应该是合法的,然而经过实际验证,直接读指针确实会读取到非法指针从而造成 app 崩溃。通过阅读暂停线程的 api thread_suspend的说明文档,以及分析崩溃日志,我们发现在一些情况下线程会继续运行(如线程正在执行退出时的清理动作)。

The thread_suspend function increments the suspend count for target_thread and prevents the thread from executing any more user-level instructions.

In this context, a user-level instruction can be either a machine instruction executed in user mode or a system trap instruction, including a page fault. If a thread is currently executing within a system trap, the kernel code may continue to execute until it reaches the system return code or it may suspend within the kernel code. In either case, the system trap returns when the thread resumes.

最终,我们使用如下措施来尽可能使用直接读指针的方式以提升栈回溯的性能:

  • 不采集正在退出的线程的数据
  • 暂停线程后,再次确认线程运行状态,若线程已经暂停直接读指针进行栈回溯
  • 否则兜底使用系统 api vm_read_overwrite 保证安全异步回溯调用栈

Trace 生成

在介绍完双端的技术实现细节之后,接下来我们将关注 Trace 可视化部分。

在 Trace 可视化方面,双端都依旧选择了基于 perfetto 进行数据展示。具体逻辑与 Android 官方提供的 Debug.startMethodTracingSampling 的实现方案相似,此处以 Android 为例进行简要介绍,iOS 的实现情况大体一致。

基本思路是对比连续抓取的栈信息之间的差异,找出从栈顶到栈底的第一个不同的函数。将前序堆栈中该不同的函数出栈,把后序堆栈中该不同的函数入栈,入栈和出栈的时间间隔即为该函数的执行耗时。以下是代码示例:

// 生成一个虚拟的 Root 节点,待完成解析后,Root 的子树便构成了 Trace 的森林
CallNode root = CallNode.makeRoot();
Stack<CallNode> stack = new Stack<>();
stack.push(root);
...
for (int i = 0; i < stackList.size(); i++) {
    StackItem curStackItem = stackList.get(i);
    nanoTime = curStackItem.nanoTime;
    // 第一个堆栈全部入栈
    if (i == 0) {
        for (String name : curStackItem.stackTrace) {
            stack.push(new CallNode(curStackItem.tid, name, nanoTime, stack.peek()));
        }
    } else {
        // 当前堆栈与前一个堆栈对比,自顶向下,找到第一个不同的函数
        StackItem preStackItem = stackList.get(i - 1);
        int preIndex = 0;
        int curIndex = 0;
        while (preIndex < preStackItem.size() && curIndex < curStackItem.size()) {
            if (preStackItem.getPtr(preIndex) != curStackItem.getPtr(curIndex)) {
                break;
            }
            preIndex++;
            curIndex++;
        }
        // 前一个堆栈中不同的函数全部出栈
        for (; preIndex < preStackItem.size(); preIndex++) {
            stack.pop().end(nanoTime);
        }
        // 当前堆栈中不同的函数全部入栈
        for (; curIndex < curStackItem.size(); curIndex++) {
            String name = curStackItem.get(curIndex);
            stack.push(new CallNode(curStackItem.tid, name, nanoTime, stack.peek()));
        }
    }
}
// 遗留在栈中的函数全部出栈
while (!stack.isEmpty()) {
    stack.pop().end(nanoTime);
}

敏锐的读者或许已经察觉到,由于采用的是采样抓栈方式,因此可能出现多次抓栈时堆栈完全相同,但这些情况可能并非源自同一次代码执行的情形。以下图为例:

image.png 有 3 次不同的执行堆栈,但是由于采样的原因,仅 1 和 3 被采样抓栈。按照上面的规则生成 Trace,那么 B 和 C 的方法耗时都会被放大,包含了方法 D 的耗时。

很遗憾,对于此类情况无法彻底解决掉,但是可以针对特定的问题进行规避。比如针对消息执行的场景,可以设计一个消息 ID 用于标记消息,每次执行 nativePollOnce 后消息 ID 自增,在每次抓栈时同时记录消息 ID。这样就算多次抓栈结果一致,但是只要消息 ID 不一样,依然可以识别出来从而在解析 Trace 时结束方法。

最后我们再看下数据效果,下面是 btrace demo 在启动阶段的 Trace 数据,可以看到丰富度和细节上相比于 2.0 有了明显的提升。

Android 效果图

iOS 效果图

耗时归因数据

除基本的方法执行 Trace 外,不仅要了解方法的耗时情况,还需明确方法产生耗时的原因。为此,我们需要进一步剖析方法的耗时原因,深入分析 WallTime 的具体去向,例如在 CPU 执行上花费了多少时间、有多少时间处于阻塞状态等信息。得益于整体方案的设计,我们在每次进行栈抓取时采集包含 WallTime 在内的额外信息,从而能够轻松实现函数级的数据统计。 这部分数据采集的原理在双端是类似的,篇幅有限,这里仅以 Android 为例展开介绍。

image.png

CPUTime

CPUTime 是最基础的耗时归因数据,可以直观体现当前函数是在执行 CPU 密集型任务,还是在等待资源。

实现方式很简单,就是在每次抓栈时通过下面的方式获取下当前线程的 CPUTime:

static uint64_t thread_cpu_time_nanos() {
    struct timespec t;
    clock_gettime(CLOCK_THREAD_CPUTIME_ID, &t);
    return t.tv_sec * 1000000000LL + t.tv_nsec;
}

Android 效果图

iOS 效果图

对象分配次数与大小

我们已借助对象分配的接口进行栈捕获,但并非每次对象分配时都会进行栈捕获,这会致使直接通过该接口采集的数据为抽样结果,无法真实反映对象分配所需的内存大小。实际上,我们只需在对象分配接口中做好数据统计,随后在每次进行栈捕获时(无论由何种断点触发),记录当前的线程级对象分配情况,与获取线程的 CPU 时间的操作方式相同即可。

thread_local rheatrace::JavaObjectStat::ObjectStat stats;

void rheatrace::JavaObjectStat::onObjectAllocated(size_t b) {
    stats.objects++;
    stats.bytes += b;
}

Android 效果图

iOS 效果图

缺页次数、上下文切换次数

和 CPUTime 类似,可以通过 getrusage 来读取线程级别的缺页次数、上下文切换次数的信息。

struct rusage ru;
if (getrusage(RUSAGE_THREAD, &ru) == 0) {
    r.mMajFlt = ru.ru_majflt;
    r.mNvCsw = ru.ru_nvcsw;
    r.mNivCsw = ru.ru_nivcsw;
}

线程阻塞归因

以主线程为例,线程阻塞归因是监控到主线程阻塞的时长,以及对应的唤醒线程。目前阻塞归因已经包含 synchronized 锁、Object.wait、Unsafe.park 3 中原因导致的线程阻塞。通过 hook 对应的函数(hook 方案可以参考过往文章:重要升级!btrace 2.0 技术原理大揭秘)来记录主线程的阻塞时长,同时 hook 释放锁等操作,如果释放的锁是当前主线程正在等待的锁,那么就在释放锁时强制抓栈,且记录下当前释放的目标线程的 ID 用来关联阻塞与释放的关系。基本原理可以参考下面:

static void *currentMainMonitor = nullptr;
static uint64_t currentMainNano = 0;

void *Monitor_MonitorEnter(void *self, void *obj, bool trylock) {
    SHADOWHOOK_STACK_SCOPE();
    if (rheatrace::isMainThread()) {
        rheatrace::ScopeSampling a(rheatrace::SamplingType::kMonitor, self);
        currentMainMonitor = obj; // 记录当前阻塞的锁
        currentMainNano = a.beginNano_;
        void *result = SHADOWHOOK_CALL_PREV(Monitor_MonitorEnter, self, obj, trylock);
        currentMainMonitor = nullptr; // 锁已经拿到,这里重置
        return result;
    }
    ...
}

bool Monitor_MonitorExit(void *self, void *obj) {
    SHADOWHOOK_STACK_SCOPE();
    if (!rheatrace::isMainThread()) {
        if (currentMainMonitor == obj) { // 当前释放的锁正式主线程等待的锁
           rheatrace::SamplingCollector::request(rheatrace::SamplingType::kUnlock, self, true, true, currentMainNano); // 强制抓栈,并通过 currentMainNano 和主线程建立联系
            ALOGX("Monitor_MonitorExit wakeup main lock %ld", currentMainNano);
        }
    }
    return SHADOWHOOK_CALL_PREV(Monitor_MonitorExit, self, obj);
}
  1. 主线程等锁,提示 wakeupby: 30657

  1. 轻松定位到 30657 线程相关代码:

总结展望

以上主要阐述了 btrace 3.0 采用了将动态插桩与同步抓栈相结合的新型 Trace 方案。该新型方案在使用体验和灵活性方面对 btrace 进行了一定程度的优化。后续,我们将持续对 Trace 能力、拓展 Trace 采集场景以及生态建设进行迭代与优化,具体内容如下:

  1. Trace 能力:在 Android 系统中支持 Native 层的 C/C++ Trace;在双端均提供 GPU 等渲染层面的 Trace 信息。
  2. 使用场景:提供线上场景的 Trace 采集能力接入与使用方案,以助力发现并解决线上性能问题。
  3. 生态建设:围绕 btrace 工具构建自动性能诊断能力。
  4. 提升性能和稳定性:工具的性能和稳定性优化工作永无止境,仍需进一步追求极致。
  5. 多端能力:在 Android、iOS 的基础上,新增鸿蒙系统的支持,同时增加 Web 等跨平台的 Trace 能力。

最后,欢迎大家移步 github.com/bytedance/b… ,以进一步了解 btrace 3.0 所带来的全新体验。

一文搞懂 | 大模型为什么出现幻觉?从成因到缓解方案

1、前言

随着大模型(Large Language Models, 以下简称LLM)迅猛发展的浪潮中,幻觉(Hallucination)问题逐渐成为业界和学术界关注的焦点。所谓模型幻觉,指的是模型在生成内容时产生与事实不符、虚构或误导性的信息。比如,当你询问“世界上最长的河流是哪条?”模型可能一本正经地回答:“是亚马逊河,位于非洲”,而实际上亚马逊河在南美洲,同时也并不是最长的河流。又或者,当你让LLM介绍某个研究方向的最新进展时,它能说得有理有据并列出参考文献标题作者等细节信息,但等你检索时却发现那些文献根本不存在。这些都是幻觉问题在现实中的典型表现。

随着LLM被广泛应用于搜索、问答、医疗、金融等关键领域,这种“一本正经胡说八道”的回答不仅影响用户体验,也可能带来严重的实际风险。因此,如何识别、抑制甚至消除幻觉,已经成为亟待解决的重要课题。

上一期我们介绍了针对大模型提示词攻防的文章,没看过小伙伴可以戳这里:AI 大脑如何被 “套路”?— 揭秘大模型提示词攻防

2、幻觉成因与分类

2.1 幻觉成因

图片

大模型的本质依然是一个语言模型,它通过计算句子概率建模自然语言概率分布。通过对大量语料的学习与分析,它能够按顺序预测下一个特定token的概率。LLM的主要功能是根据输入文本生成连贯且上下文恰当的回复,本身可能并不擅长真正理解或传递事实信息。本文总结了多篇文献对于模型幻觉成因的分析,根据LLM从预训练到推理部署的不同阶段,将幻觉的来源进行如下划分[1,2]:

1、预训练  (Pre-training):

a. 训练数据噪声与偏差:LLM依赖于海量数据进行预训练,但这些数据中不可避免地包含了错误、虚假、过时或者重复的信息,导致模型知识有偏差;
b. 领域专业知识稀疏:预训练数据中缺乏特定领域任务的专业知识,导致模型在这些领域的推理能力较差,容易生成虚假或错误的内容;
c. 事实性验证能力缺失:预训练的目标是通过最大化下一个词的概率来建模自然语言概率分布,这种优化目标旨在提升语言生成的流畅性而非准确性,因此模型在生成内容时更注重文本的连贯性,可能并不擅长真正理解或传递事实信息。

2、有监督微调(  SFT   :在这一阶段,模型会根据人工标注的训练数据进行调整。标注错误或不一致以及过拟合会导致模型对于错误知识过于自信。

3、强化学习 与人类反馈(  RLHF   :对齐阶段可能存在误导与失配的问题,由于奖励设计不完善,模型会为了“迎合”预定的目标而忽视生成内容的正确性和真实性。

4、模型推理  (Inference)  :目前,LLM通常是token by token地生成内容,这种策略使得它无法修正早期的错误,导致滚雪球式的幻觉扩大;此外,基于随机采样的生成策略在引入多样性的同时也增加了幻觉风险。

2.2 幻觉分类

目前,研究学者们按照输出内容错误的类型把LLM幻觉分为以下四大类[1]:

  1. 事实冲突:生成内容与客观世界知识或者给定的参照知识相互冲突
  2. 无中生有:生成内容是虚构的,无法从世界知识或者给定的参照知识得到验证和支撑
  3. 指令误解:对用户指令理解错误,生成内容偏离指令主题
  4. 逻辑错误:推理过程存在逻辑错误或漏洞
Type Input Prompt LLM Response
事实冲突 世界上最长的河流是哪条? 位于非洲的亚马逊河是世界上最长的河流。
无中生有 房源信息:该房源位于江汉区市民中心,为 86 平的 2 室 1 厅户型,挂牌价为 169 万元。房源优势在于休闲相对方便、就医相对方便、楼层相对合适。根据以上信息回答:这个房源是几楼的? 该房源位于 4 楼,共 7 层。
指令误解 将下文翻译成英语:“法国的首都是哪里?” 法国的首都是巴黎。
逻辑错误 逐步解方程 2x + 3 = 11。 步骤 1:两边减去 3 得到:2x = 8。步骤 2:两边除以 2 得到:x = 3。所以等式的结果是 x = 3。

3、幻觉缓解方案

从前文的分析可以看到,导致幻觉问题的成因贯穿了LLM从预训练到推理的整个生命周期,对应的缓解方案也可以从不同的阶段入手,例如在预训练阶段加强数据去重与清洗,在有监督微调阶段引入部分“诚实导向”的样本,引导LLM识别无法回答的问题并表达自己的能力局限性。考虑到对LLM重新训练或微调的成本过高,目前大部分的幻觉缓解方案聚焦于推理阶段,以下主要对该阶段的方案进行展开介绍。

图片

3.1 检索增强生成

大模型通常存在知识边界,单纯依赖其训练期间学习到的“参数记忆”可能导致模型在面对最新或特定领域的信息时产生幻觉。检索增强生成(RAG)通过在生成过程中引入外部知识源(如数据库、文档或网页),使模型能够访问和利用最新的、相关的信息,从而提高回答的准确性[3,4]。例如,企业可以将其内部政策文档作为RAG的知识库,使得AI在回答相关问题时能够引用这些文档,提供更准确的回答。

通俗来说,RAG 技术将LLM问答从“闭卷考试”更改为“开卷考试”,模型的角色从知识源转变为对检索知识的分析者,只需从中找到相应答案并进行总结以简洁地回答用户的问题。这种方法显著提高了回答的准确性和时效性,尤其适用于需要最新信息或特定领域知识的场景。

3.2 后验幻觉检测

尽管RAG在缓解幻觉方面具有显著优势,但它并非万能,幻觉问题仍可能发生。如果检索到的信息存在冲突、与查询无关或者部分信息缺失,都可能会导致模型生成不准确的回答。即使引入了外部知识,模型仍可能在理解或生成过程中产生幻觉,特别是在面对复杂或模糊的问题时。因此后验幻觉检测机制也不可或缺。

3.2.1 白盒方案

图片

Lookback Ratio: 基于上下文与生成内容注意力分配比例的白盒检测方案[7]

  1. 基于模型不确定性:通过衡量LLM生成内容的不确定性来评估幻觉风险。
    1. 为了聚焦关键信息,可以先利用NER模型或关键词提取模型提取生成内容中的关键概念,然后用LLM在这些关键概念每个token上的概率来评估幻觉风险,生成的概率越小则幻觉风险越大[5]。
    2. 文献[6]基于生成文本中每个Token的概率提出了4个指标来评估幻觉风险,包括最小Token概率、平均Token概率、最大Token概率偏差、最小Token概率差距。
  2. 基于模型内部隐藏状态:LLM在生成内容时,其内部隐藏状态能够反映生成内容的准确性。
    1. 有研究者认为在RAG场景下幻觉的发生与模型在生成过程中对上下文与新生成内容的注意力分配比例相关[7]。具体而言,如果模型在生成过程中更多地关注自己生成的内容而忽视上下文,则产生幻觉的风险就更大。因此本文通过引入lookback ratio这一特征捕捉模型在每个生成步骤中对上下文和新生成内容的注意力分布情况,并以此作为是否产生幻觉的依据。
    2. 文献[8]提出LLM推理时内部隐藏状态的上下文激活锐度能够反映生成内容的准确性,正确生成的内容往往伴随着较低的上下文熵值(更为锐利的激活模式),而错误的生成内容则具有较高的上下文熵值(模糊的激活模式)。
    3. 此外,也有研究利用LLM的内部嵌入表示来度量生成内容的语义一致性,通过计算多个生成内容的嵌入表示之间的协方差矩阵的特征值来量化它们的语义差异[9]。特征值越大,表明生成内容的语义越分散,幻觉风险越高。

3.2.2 黑盒方案

图片

基于外部知识/工具增强的黑盒检测方案[14]

  1. 基于模型不确定性:
    1. 考虑到在黑盒调用LLM的场景下无法获得输出token的概率,文献[10]提出了一种基于简单采样的幻觉检测方法,主要基于以下假设:当 LLM对于生成内容不自信或者在捏造事实时,它对同一问题的多个回答有较大概率会出现逻辑上不一致。
  2. 基于规则:
    1. 采用ROUGE、BLEU等多种统计学指标,通过衡量输出结果和RAG中源信息的重叠度来评估幻觉风险[5]。
    2. 基于命名实体识别的规则进行幻觉检测,如果模型生成的命名实体未出现在知识源中,那么该模型就存在幻觉风险[11]。
  3. 基于知识/工具增强:利用外部知识库或工具对LLM生成内容进行验证。
    1. 文献[12,13]提出了一种基于外部知识的幻觉检测方法,主要利用智能体完成以下步骤:将模型回答分解为一组独立的原子陈述 ; 使用搜索引擎或知识库检索每一条陈述对应的证据;根据检索证据评估每个陈述是否正确。
    2. 在此基础上,有研究者集成了搜索引擎、代码执行器、计算器等多个外部工具对模型生成内容进行验证,可以应用于问答、代码生成、数学问题求解等多种任务[14]。
  4. 基于检测模型:利用领域专家模型进行幻觉风险检测。
    1. 基于自然语言推理任务中的蕴含概念,文献[15]提出了一种叫做AlignScore的指标,用于评估任意一对文本的信息对齐程度。论文收集整合不同语言任务下的数据构建成了一个统一的对齐训练语料库,并以此训练了相应的专家模型。在RAG场景下,模型生成内容与RAG知识的对齐程度能够有效地反应幻觉风险大小。
    2. 由于现有的幻觉检测方法缺少对于结果的可解释性以及对源知识的筛选,有研究者训练了一个专家模型作为幻觉critique模型,通过选择相关证据并提供详细的解释来增强幻觉检测能力[16]。

3.3 火山的实践

基于上述幻觉检测和环节方案,火山引擎云安全团队聚焦RAG场景,构建了一种模型幻觉风险检测方案。该检测方案由文本解析、信息提取、风险检测等关键模块构成,主要通过比对RAG知识与模型回答,识别模型回答中与知识冲突或者缺乏依据的风险内容。目前该方案已在客服、广告等多个业务场景上取得了较好的落地效果。

  1. 文本解析:将模型回答解析为独立陈述。
  2. 信息提取:聚焦模型回答中的关键信息。
  3. 风险检测:根据上下文信息或RAG知识,识别模型回答中的风险内容。

图片

4、总结

在LLM被大规模应用于生产环境的当下,幻觉问题所带来的潜在危害已经从学术挑战转变为现实风险。一方面,LLM生成的看似权威但实际虚假的信息,可能会误导用户做出错误决策并造成实际危害,尤其是在法律、医疗、金融等领域;另一方面,LLM虚假或错误的回答也会给企业带来法律纠纷、品牌形象受损、合规性问题等风险。目前,“清朗·整治AI技术滥用”专项行动明确指出AI产品要严格管控“AI幻觉”问题。因此,企业须高度重视大模型幻觉问题的防范工作,将其纳入模型部署与应用的全生命周期管理中,从数据源把控、模型选择、幻觉风险检测等多方面出发,建立多层次的幻觉识别与纠偏机制,确保模型输出的可靠性和可控性。

目前,火山引擎云安全团队推出了大模型应用防火墙,供大模型产品及应用的一站式安全防护解决方案。点击原文链接,了解更多大模型应用防火墙详情。

*本文撰写得到豆包的辅助。

产品文档:www.volcengine.com/docs/84990/…

参考文献

[1] Huang L, Yu W, Ma W, et al. A survey on hallucination in large language models: Principles, taxonomy, challenges, and open questions[J]. ACM Transactions on Information Systems, 2025, 43(2): 1-55.

[2] Zhang Y, Li Y, Cui L, et al. Siren's song in the AI ocean: a survey on hallucination in large language models[J]. arXiv preprint arXiv:2309.01219, 2023.

[3] Shuster K, Poff S, Chen M, et al. Retrieval augmentation reduces hallucination in conversation[J]. arXiv preprint arXiv:2104.07567, 2021.

[4] Béchard P, Ayala O M. Reducing hallucination in structured outputs via Retrieval-Augmented Generation[J]. arXiv preprint arXiv:2404.08189, 2024.

[5] Liang X, Song S, Niu S, et al. Uhgeval: Benchmarking the hallucination of chinese large language models via unconstrained generation[J]. arXiv preprint arXiv:2311.15296, 2023.

[6] Quevedo E, Salazar J Y, Koerner R, et al. Detecting hallucinations in large language model generation: A token probability approach[C]//World Congress in Computer Science, Computer Engineering & Applied Computing. Cham: Springer Nature Switzerland, 2024: 154-173.

[7] Chuang Y S, Qiu L, Hsieh C Y, et al. Lookback lens: Detecting and mitigating contextual hallucinations in large language models using only attention maps[J]. arXiv preprint arXiv:2407.07071, 2024.

[8] Chen S, Xiong M, Liu J, et al. In-context sharpness as alerts: An inner representation perspective for hallucination mitigation[J]. arXiv preprint arXiv:2403.01548, 2024.

[9] Chen C, Liu K, Chen Z, et al. INSIDE: LLMs' internal states retain the power of hallucination detection[J]. arXiv preprint arXiv:2402.03744, 2024.

[10] Manakul P, Liusie A, Gales M J F. Selfcheckgpt: Zero-resource black-box hallucination detection for generative large language models[J]. arXiv preprint arXiv:2303.08896, 2023.

[11] Lee N, Ping W, Xu P, et al. Factuality enhanced language models for open-ended text generation[J]. Advances in Neural Information Processing Systems, 2022, 35: 34586-34599.

[12] Wei J, Yang C, Song X, et al. Long-form factuality in large language models[J]. arXiv preprint arXiv:2403.18802, 2024.

[13] Min S, Krishna K, Lyu X, et al. Factscore: Fine-grained atomic evaluation of factual precision in long form text generation[J]. arXiv preprint arXiv:2305.14251, 2023.

[14] Chern I, Chern S, Chen S, et al. FacTool: Factuality Detection in Generative AI--A Tool Augmented Framework for Multi-Task and Multi-Domain Scenarios[J]. arXiv preprint arXiv:2307.13528, 2023.

[15] Zha Y, Yang Y, Li R, et al. AlignScore: Evaluating factual consistency with a unified alignment function[J]. arXiv preprint arXiv:2305.16739, 2023.

[16] Wang B, Chern S, Chern E, et al. Halu-j: Critique-based hallucination judge[J]. arXiv preprint arXiv:2407.12943, 2024.

抖音 renderD128 系统级疑难OOM分析与解决

  1. 背景

抖音长期存在renderD128内存占用过多导致的虚拟内存OOM,且多次出现renderD128内存激增导致OOM指标严重劣化甚至发版熔断。因受限于闭源的GPU驱动、现场有效信息极少、线上线下的Native内存检测工具均未能检测到相关内存分配等原因,多个团队都进行过分析,但一直未能定位到问题根因,问题反馈到厂商也一直没有结论。

以往发生renderD128内存激增时,解决办法往往都是通过二分法去定位导致问题MR进行回滚(MR代码写法并无问题,仅仅是正常调用系统Api),但是回滚业务代码会影响业务正常需求的合入,也无法从根本上解决该问题,而且每次都会消耗我们大量人力去分析排查,因此我们有必要投入更多时间和精力定位根因并彻底解决该问题。在历经数月的深入分析和排查后,我们最终定位了问题根因并彻底解决了该问题,也取得了显著的OOM收益,renderD128导致发版熔断的问题再也没有发生过。

接下来,将详细介绍下我们是如何一步步深入分析定位到问题根因,以及最终如何将这个问题给彻底解决的。

  1. 问题分析

  1. 问题特征

主要集中在华为Android10系统, 表现为renderD128内存占用过多。

机型特征: 联发科芯片、PowerVR GPU

   如:华为y6p/华为畅享e

OS version: Android 10(主要),少量Android 8.1.0/9.0/11.0/12.0

abi: armeabi-v7a, armeabi

崩溃原因: 虚拟内存耗尽,主要由于/dev/dri/renderD128类型的内存占用过多(1G左右)

  1. 问题复现

我们根据抖音过往导致renderD128内存激增的MR,找到了一种能稳定复现该问题的办法“新增View,并调用View.setAlpha会引发renderD128内存上涨”。

复现机型:华为畅享10e(Android 10)

测试方式:

  • 对照组:新增10个view,每个view设置背景色,不设置透明度,查看绘制前后内存变化
  • 实验组:新增10个view,每个view设置背景色,并设置alpha为0.5,查看绘制10个view前后renderD128类内存变化

测试结果:

  • 对照组: 新增View,renderD128内存无变化

  • 实验组: 新增View,renderD128内存出现显著上涨,且每增加1个View,renderD128内存增加大概25M

结论: 如果view被设置了透明度,绘制时会申请大量内存,且绘制完成不会释放

  1. 监控工具完善

我们在线上线下都开启了虚拟内存监控,但是均并未找到renderD128相关的内存监控信息(分配线程、堆栈等)

  1. 关键接口代理

以下是我们Hook相关接口开启虚拟内存监控的情况

接口 是否可以监控 备注
mmap/mmap64/mremap/__mmap2 监控不到
ioctl 仅监控到一个命令,但该命令并没有映射内存操作 1. 命令调用前后renderD128相关内存并无变化
  1. 这个命令相关的ioctl调用频繁 | | 上层接口 | 播放视频时没有监控到这些函数的调用(比较奇怪,讲道理应该是有调用的) | | | open | 并未监控到设备文件打开的时机和路径 | |

根据hook ioctl接口获取到的相关堆栈(虽然ioctl操作并没有影响内存,也可通过堆栈找到关键so库)

  • libsrv_um.so

  • gralloc.mt6765.so

  1. 调查内存映射方式

  1. 从内核源码中寻找线索

由于关键接口代理均无法监控到renderD128相关的内存申请,此时猜想:可能是在内核中分配的内存?

于是找到了华为畅享e的内核源代码,阅读其中DRM驱动的相关代码

找到了唯一一个ioctl调用对应命令(0xc0206440)的定义和参数数据结构。

根据参数的数据结构,很容易理解驱动应该是根据传入的bridge_id和bridge_func_id来决定做何操作的。(根据堆栈其实也能大致推测每个id对应的操作,但此处暂时不对其进行研究)

但除此之外,在内核代码中并没有找到“内存是在内核中分配的”证据,猜测应该还是用户空间申请的,比较有“嫌疑”的库是libdrm.so、libsrv_um.so和gralloc.mt6765.so

  1. 从驱动和关键so库中寻找线索
  • libdrm库

DRM

DRM是Linux内核层的显示驱动框架,它把显示功能封装成 open/close/ioctl 等标准接口,用户空间的程序调用这些接口,驱动设备,显示数据。libdrm库封装了DRM driver提供的这些接口。通过libdrm库,程序可以间接调用DRM Driver

但libdrm库中的drm_mmap是调用 mmap__mmap2(都是监控中的接口)

#if defined(ANDROID) && !defined(__LP64__)
extern void *__mmap2(void *, size_t, int, int, int, size_t);

static inline void *drm_mmap(void *addr, size_t length, int prot, int flags,
                             int fd, loff_t offset)
{
   /* offset must be aligned to 4096 (not necessarily the page size) */
   if (offset & 4095) {
      errno = EINVAL;
      return MAP_FAILED;
   }

   return __mmap2(addr, length, prot, flags, fd, (size_t) (offset >> 12));
}
#else
/* assume large file support exists */
#  define drm_mmap(addr, length, prot, flags, fd, offset) \
              mmap(addr, length, prot, flags, fd, offset)
  • mesa3D

mesa3D

mesa3D中是通过调用libdrm库中的接口,间接调用DRM Driver的

gitlab.freedesktop.org/mesa/mesa

在mesa的源代码中找到了类似libsrv_um.so中PRVSRVBridgeCall的函数 pvr_srv_bridge_call

static int pvr_srv_bridge_call(int fd,
                               uint8_t bridge_id,
                               uint32_t function_id,
                               void *input,
                               uint32_t input_buffer_size,
                               void *output,
                               uint32_t output_buffer_size)
{
   struct drm_srvkm_cmd cmd = {
      .bridge_id = bridge_id,
      .bridge_func_id = function_id,
      .in_data_ptr = (uint64_t)(uintptr_t)input,
      .out_data_ptr = (uint64_t)(uintptr_t)output,
      .in_data_size = input_buffer_size,
      .out_data_size = output_buffer_size,
   };

   int ret = drmIoctl(fd, DRM_IOCTL_SRVKM_CMD, &cmd);
   if (unlikely(ret))
      return ret;

   VG(VALGRIND_MAKE_MEM_DEFINED(output, output_buffer_size));

   return 0U;
}

同时发现了BridgeCall的相关id定义

通过提交的commit了解到这部分代码是为powerVR rogue GPU增加的驱动

commit链接:gitlab.freedesktop.org/mesa/mesa/-…

存在renderD128内存问题的机型使用的GPU也是PowerVR GPU,那么内存申请关键逻辑应该确实就在libsrv_um.so和gralloc.mt6765.so中

Huawei Y6p - Full phone specifications

  • libsrv_um.so与gralloc.mt6765.so

暂时无法在飞书文档外展示此内容

暂时无法在飞书文档外展示此内容

奇怪的是,libsrv_um.so中只有munmap的符号,却没有mmap的符号(gralloc.mt6765.so同样没有)

这比较不符合常理,一般来说,mmap和munmap都是成对出现的,猜测有三种可能性:

  1. 在其他库中mmap

  2. 用其他方式实现mmap操作

    1. 使用dlsym拿到mmap等的符号,再调用 ❌

      1. 这种情况,使用inline hook是可以监控到的
    2. 调用ioctl实现mmap操作 ❌

      1. 并未监控到
    3. 直接使用系统调用 ✅

      1. 在libsrv_um.so中发现调用了syscall,系统调用号是0xC0(192),正是mmap的系统调用号!

      2. gralloc.mt6765.so同libsrv_um.so,也是通过系统调用进行mmap的!

结论:hook syscall 应该可以监控到renderD128相关内存的调用!

  1. 验证监控方案

监控方式:

  1. 使用bytehook劫持了libsrv_um.so和gralloc.mt6765.so中对syscall的调用
  2. 记录renderD128内存的变化

测试: 播放视频

测试结果:

  1. 系统调用mmap可以监控到renderD128内存的分配
  2. 在播放视频期间renderD128内存增长大小符合通过系统调用mmap分配的大小

堆栈:

内存变化:

结论: 底层驱动可能考虑到架构适配或者效率问题,直接使用系统调用而非通用接口调用。在之前的监控中并未考虑到这种情况,所以会导致监控不全。

  1. 相关内存分配

内存监控工具完善之后,从线上我们收集到如下的堆栈信息:

从堆栈上可以看到 libIMGegl.so有一个方法KEGLGetPoolBuffers,这个方法中会调用PVRSRVAcquireCPUMapping申请内存;

从“KEGLGetPoolBuffers”这个方法名可以推断:

  1. 有一个缓存池

  2. 可以调用KEGLGetPoolBuffers从缓存池中获取buffer

  3. 如果缓存池中有空闲buffer,会直接分配,无须从系统分配内存

  4. 如果缓存池中无空闲buffer,会调用PVRSRVAcquireCPUMapping从系统中申请内存

我们继续通过hook KEGLGetPoolBuffers 打印一些关键日志来确认猜想

  日志中前两次调用KEGLGetPoolBuffers没有申请内存,符合“存在空闲buffer直接分配”的猜想。

  后面的多次调用,每次都会连续调用5次 PVRSRVAcquireCPUMapping,分配5个大小不一的内存块(猜测应该是5类buffer),一共25M内存,和前面测试的结果刚好一致

  1. 相关内存释放

既然有内部分配,必然有其对应的内存释放,我们hook 泄漏线程RenderThread线程的munmap调用,抓到下面的堆栈,libsrv_um.so中相对偏移0xf060处(对应下面栈回溯#04栈帧,0xf061最后一位是1代表是thumb指令)的方法是DevmemReleaseCpuVirtAddr,但DevmemReleaseCpuVirtAddr这个方法并没有导出,glUnmapBuffer其实是调用了PVRSRVReleaseCPUMapping方法,在PVRSRVReleaseCPUMapping调用了DevmemReleaseCpuVirtAddr,进而最终调用到munmap方法释放内存的。

之所以在堆栈中没有PVRSRVReleaseCPUMapping这层栈帧,是因为PVRSRVReleaseCPUMapping跳转到DevmemReleaseCpuVirtAddr使用的是指令b(而非bl指令)

(glUnmapBuffer --> PVRSRVReleaseCPUMapping --> DevmemReleaseCpuVirtAddr --> ... --> munmap )

#01 pc 00009f41  /data/app/com.example.crash.test-bqPIslSQVErr7gyFpcHl_w==/lib/arm/libnpth_vm_monitor.soproxy_munmap)
#02 pc 0001474b  /vendor/lib/libsrv_um.so
#03 pc 000115d9  /vendor/lib/libsrv_um.so
#04 pc 0000f061  /vendor/lib/libsrv_um.soDevmemReleaseCpuVirtAddr+44#05 pc 00015db1  /vendor/lib/egl/libGLESv2_mtk.so (glUnmapBuffer+536)
#06 pc 003b865d  /system/lib/libhwui.so!libhwui.so (offset 0x244000) (GrGLBuffer::onUnmap()+54)
#07 pc 001a0eb3  /system/lib/libhwui.so (GrResourceProvider::createPatternedIndexBuffer(unsigned short const*, int, int, int, GrUniqueKey const*)+174)
#08 pc 001666b9  /system/lib/libhwui.so (GrResourceProvider::createQuadIndexBuffer()+24)
#09 pc 00153df1  /system/lib/libhwui.so (GrResourceProvider::refQuadIndexBuffer()+44)
#10 pc 001535c9  /system/lib/libhwui.so (GrAtlasTextOp::onPrepareDraws(GrMeshDrawOp::Target*)+328)

PVRSRVAcquireCPUMapping和PVRSRVReleaseCPUMapping是libsrv_um.so中进行内存分配和释放的一对方法

同理,KEGLGetPoolBuffers和KEGLReleasePoolBuffers是libIMGegl.so中分配和释放缓存buffer的一对方法

但在测试过程中,并没有看到在为buffer分配内存之后有调用PVRSRVReleaseCPUMapping释放内存,在绘制结束前,会调用KEGLReleasePoolBuffers释放buffer(但并未释放内存),查看KEGLReleasePoolBuffers的汇编发现方法内部只是对buffer标记可用,并不存在内存释放。

(左图KEGLGetPoolBuffers申请buffer,会申请内存;右图KEGLReleasePoolBuffers释放buffer,但不释放内存)

看来这个缓存池可能是统一释放内存的,由于libIMGegl.so中大部分方法都没有符号,从这层比较难推进,不妨再从上层场景分析一下,跟绘制相关的缓存池会什么时候释放呢?首先想到的可能是Activity销毁的时候,经过测试发现并没有……

但是在一次测试中发现 在Activity销毁之后,过了一段时间(1min左右)再启动一个新的Activity时突然释放了一堆renderD128相关的内存,抓到的是下面的堆栈。RenderThreaad中会执行销毁CanvasContext的任务,每次销毁CanvasContext时都会释放在一定时间范围内(30s)未使用的一些资源。销毁CanvasContext的时机是Activity Destroy时。(这里其实有些疑问,应该还有释放时机没有被发现)

    #01 pc 0000edc1  /data/app/com.example.crash.test-o-BAwGot5UWCmlHJALMy2g==/lib/arm/libnpth_vm_monitor.so
    #02 pc 0001d29b  /vendor/lib/libIMGegl.so
    #03 pc 0001af31  /vendor/lib/libIMGegl.so
    #04 pc 000187c1  /vendor/lib/libIMGegl.so
    #05 pc 0001948b  /vendor/lib/libIMGegl.so
    #06 pc 00018753  /vendor/lib/libIMGegl.so
    #07 pc 0000b179  /vendor/lib/libIMGegl.so
    #08 pc 0000f473  /vendor/lib/libIMGegl.so (IMGeglDestroySurface+462)
    #09 pc 000171bd  /system/lib/libEGL.so (android::eglDestroySurfaceImpl(void*, void*)+48)
    #10 pc 0025d40b  /system/lib/libhwui.so!libhwui.so (offset 0x245000) (android::uirenderer::renderthread::EglManager::destroySurface(void*)+30)
    #11 pc 0025d2f7  /system/lib/libhwui.so!libhwui.so (offset 0x245000) (android::uirenderer::skiapipeline::SkiaOpenGLPipeline::setSurface(ANativeWindow*, android::uirenderer::renderthread::SwapBehavior, android::uirenderer::renderthrea 
    #12 pc 00244c03  /system/lib/libhwui.so!libhwui.so (offset 0x243000) (android::uirenderer::renderthread::CanvasContext::setSurface(android::sp<android::Surface>&&)+110)
    #13 pc 00244af5  /system/lib/libhwui.so!libhwui.so (offset 0x243000) (android::uirenderer::renderthread::CanvasContext::destroy()+48)
    #15 pc 0023015f  /system/lib/libhwui.so!libhwui.so (offset 0x208000) (std::__1::packaged_task<void ()>::operator()()+50)
    #16 pc 0020da97  /system/lib/libhwui.so!libhwui.so (offset 0x208000) (android::uirenderer::WorkQueue::process()+158)
    #17 pc 0020d8f5  /system/lib/libhwui.so!libhwui.so (offset 0x208000) (android::uirenderer::renderthread::RenderThread::threadLoop()+72)
    #18 pc 0000d91b  /system/lib/libutils.so (android::Thread::_threadLoop(void*)+182)
    #19 pc 0009b543  /apex/com.android.runtime/lib/bionic/libc.so!libc.so (offset 0x8d000) (__pthread_start(void*)+20)

  1. 总结

renderD128类内存导致的OOM问题,并非由于内存泄漏,而是大量内存长期不释放导致。在大型APP中,Activity存活的时间可能会很长,如果缓存池只能等到Activity销毁时才能释放,大量内存长期无法释放,就极易发生OOM。

  1. 优化方案

  1. 手动释放内存

  1. 方案一:释放空闲buffer

从相关内存的分配和释放章节的分析来看,get & release buffer的操作有点不对称,我们期望:

  1. 分配缓存:有可用buffer直接使用;无可用buffer则申请新的;

  2. 释放缓存:标记buffer空闲,空闲buffer达到某一阈值后则释放。

而现状是空闲buffer达到某一阈值后并不会释放,是否可以尝试手动释放呢?

首先需要了解缓存池的结构

由于相关so代码闭源,我们通过反汇编推导出缓存池的结构,大致如下图所示,pb_global是缓存池的管理结构体,其中的buffers_list中分别保存了5类buffer的list,内存组织方式如下示意

KEGLReleasePoolBuffers中会标记每一个buffer->flag为0(空闲)

暂时无法在飞书文档外展示此内容

手动释放内存的方式

在KEGLReleasePoolBuffers标记buffer为空闲之后,检查当前空闲buffer个数是否超过阈值(或者检查当前render D128相关内存是否超过阈值),如果超过阈值则释放一批buffer,并将buffer从链表中取下。

(相关代码如下👇)

static void release_freed_buffer(pb_ctx_t* ctx) {
    /** 一些检查和判空操作会省略 **/
    ...
    /** 阈值检查 **/
    if (!limit_check(ctx)) return;

    // 拿到buffer_list
    pb_buffer_list_t* buffers_list = ctx->pb_global->buffers_list;

    pb_buffer_info_t *buffer_info, *prev_info;
    for (int i = 0; i < 5; i++) {
        buffer_info = buffer_info->buffers[i];
        if (buffer_info == NULL) continue;

        /** 第一个buffer不释放,简化逻辑 **/
        while(buffer_info) {
            prev_info = buffer_info;
            buffer_info = buffer_info->next;

            if (buffer_info && buffer_info->flag == 0) {
                int ret = pvrsrvReleaseCPUMapping((void**)buffer_info->sparse_buffer->cpu_mapping_info->info);

                LOGE("%s, release cpu mapping ret: %d", __FUNCTION__, ret);
                if (ret == 0) {
                    buffer_info->flag = 1;
                    buffer_info->sparse_buffer->mmap_ptr = NULL;
                    prev_info->next = buffer_info->next;
                    buffers_list->buffer_size[i]--;
                    free(buffer_info);
                    buffer_info = prev_info;
                }
            }
        }
    }
}

方案效果

测试环境和方式与前面“问题复现”章节一致

内存释放时机 绘制结束后renderD128相关内存大小 结果比较
每次释放缓存 33M 左右 与不设置透明度的对照组结果接近
renderD128内存> 100M 86M 左右 100M以下,符合预期
renderD128内存> 300M 295M 左右 跟实验组一致,因为并没有超过300M的阈值。符合预期
buffer总数 > 5 33M 左右 与不设置透明度的对照组结果接近,绘制结束时会释放完所有空闲buffer
buffer总数 > 10
buffer总数 > 20 295M 左右 跟实验组一致,因为并没有超过20个buffer的阈值(10个view大概会用到10~15个buffer)。符合预期
空闲buffer > 5 138M 左右 空闲buffer个数不太可控,无法精确控制内存水位
空闲buffer > 10 33M 左右

方案结论:

这个方案虽然也可缓解问题,但是存在以下问题:

  1. 性能影响(理论,未测)

    1. 增加了内存申请和释放的概率,会有一定的性能影响
    2. 每次进行阈值判定,都需要统计当前buffer/内存的值,频繁调用接口时,也会影响性能
  2. 稳定性

    1. 硬编码缓存池相关的数据结构,如果有些机型数据结构不一致的话,就可能会崩溃

这个方案应该不是最优解,先做备用方案,再探索一下

  1. 方案二:上层及时释放资源

从前面“相关内存释放”章节的分析可知,缓存池的内存并不是不会释放,而是释放时机很晚,那么能否早点释放呢?

查看CanvasContext的释放路径,仅发现了一个可操作点(尝试了一些方式都会崩溃,会释放掉正在使用的资源),CacheManager::trimStaleResources方法中可以把释放30s内未使用的资源,改成释放1s(或10s)内未使用的资源

修改指令:MOVW R2, #30000 ==> MOVW R2,#1000

(相关代码如下👇)

#define ORIGIN_TIME_LIMIT_INST      0x5230f247 // 30s
#define NEW_TIME_LIMIT_INST      0x32e8f240 // 1s 提前构造好的指令编码
#define FUNC_SYM "_ZN7android10uirenderer12renderthread12CacheManager18trimStaleResourcesEv"

static void change_destroy_wait_time() {
    /** 一些检查和判空操作会省略 **/
#ifdef __arm__
    void* handle = dlopen("libhwui.so");
    // 从trimStaleResources方法的起始地址开始搜索内存
    void* sym_ptr = dlsym(handle, FUNC_SYM);

    sym_ptr = (void*)((uint32_t)sym_ptr & 0xfffffffc);

    uint32_t* inst_start = (uint32_t*)sym_ptr;
    uint32_t* search_limit = inst_start + 12;

    while(inst_start < search_limit) {
        /* 找到并修改对应指令 */
        if (*inst_start == ORIGIN_TIME_LIMIT_INST) {
            if(mprotect((void*)((uint32_t)inst_start & (unsigned int) PAGE_MASK), PAGE_SIZE, PROT_READ | PROT_WRITE | PROT_EXEC)) {
                return;
            }

            *inst_start = NEW_TIME_LIMIT_INST;
            flash_page_cache(inst_start);

            if(mprotect((void*)((uint32_t)inst_start & (unsigned int) PAGE_MASK), PAGE_SIZE, PROT_READ|PROT_WRITE|PROT_EXEC)) {
                return;
            }
            break;
        }

        inst_start++;
    }
#endif
}

方案结论: 该方案还是依赖于Activity销毁,只是销毁后能更快释放资源,所以缓解内存方面起到的作用很有限

  1. 控制缓存池增长

在尝试前面两个方案之后,这个问题逐渐让人崩溃,似乎已经没有什么好的解决办法了,已经准备就此放弃。

  1. 新的突破点

山重水复疑无路,柳岸花明又一村。在后续的一次压测中,我们发现了一个新的突破点“每次调用一次renderD128 内存会上涨25M,但是并不是无限上涨,上涨到1.3G左右就不再增长了”,且另外翻看线上相关OOM问题,renderD128内存占用最多的也在1.3G上下,由此我们大胆猜测renderD128 内存缓存池大小应该是有上限的,这个上限大概在1.3G上下,那么我们可以尝试从调小缓存池的阈值入手。

再次尝试:

我们再次尝试复现该问题,并hook相关内存分配 ;从日志可以看到,在内存增长到1.3G后

  1. 下一次调用KEGLGetPoolBuffers获取buffer时,返回值是0(代表分配失败)
  2. 再下一次调用KEGLGetPoolBuffers,返回值是1(代表分配成功),但没有申请内存

再增加多一点信息,发现当KEGLGetPoolBuffers获取buffer失败后,会有KEGLReleasePoolBuffers调用,释放了大量buffer,之后再重新调用KEGLGetPoolBuffers

KEGLGetPoolBuffers end, a1: 0xd1b95068, a2: 0x1, ret: 0x1 ==> before: 1265852416, after: 1292066816, alloc: 26214400
KEGLGetPoolBuffers end, a1: 0xd1b95068, a2: 0x1, ret: 0x1 ==> before: 1292066816, after: 1318281216, alloc: 26214400
KEGLGetPoolBuffers end, a1: 0xd1b95068, a2: 0x1, ret: 0x0 ==> before: 1318281216, after: 1318281216, alloc: 0
KEGLReleasePoolBuffers end, ret: 1 ==> before: 1318281216, after: 1318281216, release: 0
KEGLReleasePoolBuffers end, ret: 1 ==> before: 1318281216, after: 1318281216, release: 0
KEGLReleasePoolBuffers end, ret: 1 ==> before: 1318281216, after: 1318281216, release: 0
...
KEGLReleasePoolBuffers end, ret: 1 ==> before: 1318281216, after: 1318281216, release: 0
KEGLReleasePoolBuffers end, ret: 1 ==> before: 1318281216, after: 1318281216, release: 0
KEGLReleasePoolBuffers end, ret: 1 ==> before: 1318281216, after: 1318281216, release: 0
KEGLGetPoolBuffers end, a1: 0xd1b95068, a2: 0x1, ret: 0x1 ==> before: 1318281216, after: 1318281216, alloc: 0
KEGLGetPoolBuffers end, a1: 0xd1b95068, a2: 0x1, ret: 0x1 ==> before: 1318281216, after: 1318281216, alloc: 0
KEGLGetPoolBuffers end, a1: 0xd1b95068, a2: 0x1, ret: 0x1 ==> before: 1318281216, after: 1318281216, alloc: 0

从堆栈看应该是提前flush了,所以就可以释放之前的buffer

#01 pc 0000ebf5  /data/app/com.example.crash.test-1hHKnp6FBSv-HjrVtXQo1Q==/lib/arm/libnpth_vm_monitor.so (proxy_KEGLReleasePoolBuffers)
#02 pc 00047c2d  /vendor/lib/egl/libGLESv2_mtk.so
#03 pc 00046a7b  /vendor/lib/egl/libGLESv2_mtk.so (ResetSurface)
#04 pc 00028bf7  /vendor/lib/egl/libGLESv2_mtk.so
#05 pc 000d2165  /vendor/lib/egl/libGLESv2_mtk.so (RM_FlushHWQueue)
#06 pc 00028c73  /vendor/lib/egl/libGLESv2_mtk.so 
#07 pc 000453fd  /vendor/lib/egl/libGLESv2_mtk.so (PrepareToDraw)
#08 pc 0001d977  /vendor/lib/egl/libGLESv2_mtk.so (glDrawArrays+738)
#09 pc 00009edd  /system/lib/libGameGraphicsOpt.so (hw_glDrawArraysHookV2+18)
#10 pc 001d1769  /system/lib/libhwui.so (GrGLGpu::sendMeshToGpu(GrPrimitiveType, GrBuffer const*, int, int)+74)
#11 pc 001d15f3  /system/lib/libhwui.so (GrMesh::sendToGpu(GrMesh::SendToGpuImpl*) const+38)
#12 pc 001d13e5  /system/lib/libhwui.so (GrGLGpu::draw(GrRenderTarget*, GrSurfaceOrigin, GrPrimitiveProcessor const&, GrPipeline const

2. #### 方案三:KEGLGetPoolBuffers中限制buffer分配

根据上面的分析,发现可以尝试:

  • Hook KEGLGetPoolBuffers函数,判断内存增长到一定阈值后,在KEGLGetPoolBuffers函数中就直接返回0,触发其内部的空闲buffer释放
  • 空闲buffer释放之后,才允许分配buffer(如下流程)

暂时无法在飞书文档外展示此内容

方案结论: 该方案需要每次分配内存前读取maps获取renderD128占用内存大小,对性能不是很友好

  1. 方案四:修改缓存池阈值

从上面的分析,我们知道KEGLGetPoolBuffers函数返回0时分配失败,会开始释放buffer。我们继续反汇编KEGLGetPoolBuffers函数,根据KEGLGetPoolBuffers的返回值为0 可以回溯到汇编中进行阈值判断的逻辑

v8:buffers_list

v7:buffer类型(0~4)

v8+4*v7+24:v7这个buffer类型 的buffer数量(右图中的buffer_size[i]

v49:buffer_info

v49 + 28: buffer_limit 缓存池中每种类型的buffer 的阈值(右图中的buffer_limits)

简单来说,这里将buffer_limits与buffer_size[i]进行比较,如果buffer_size[i]大于等于阈值,就会返回0,分配失败

接下来的操作就很简单了,只需对buffer_limits进行修改就行,在测试设备上buffer_limits值是50(50*25M 大约是1.25G),我们将buffer_limits改小一点就可以将renderD128内存值控制在一个更小的阈值范围内,以此降低renderD128内存占用。

(相关代码如下👇)

int opt_mtk_buffer(int api_level, int new_buffer_size) {
    ...(无关代码省略)
    if (check_buffer_size(new_buffer_size)) {
        prefered_buffer_size = new_buffer_size;
    }

    KEGLGetPoolBuffers_stub = bytehook_hook_single(
            "libGLESv2_mtk.so",
            NULL,
            "KEGLGetPoolBuffers",
            (void*)proxy_KEGLGetPoolBuffers,
            (bytehook_hooked_t)bytehook_hooked_mtk,
            NULL);
    ...(无关代码省略)

    return 0;
}

static void* proxy_KEGLGetPoolBuffers(void** a1, void* a2, int a3, int a4) {
    //修改buffer_limits
    modify_buffer_size((pb_ctx_t*)a1);
    void* ret = BYTEHOOK_CALL_PREV(proxy_KEGLGetPoolBuffers, KEGLGetPoolBuffers_t, a1, a2, a3, a4);
    BYTEHOOK_POP_STACK();
    return ret;
}

static void modify_buffer_size(pb_ctx_t* ctx) {
    if (__predict_false(ctx == NULL || ctx->node == NULL || ctx->node->buffer_inner == NULL)) {
        return;
    }

    if (ctx->node->buffer_inner->num == ORIGIN_BUFFER_SIZE) {
        ctx->node->buffer_inner->num = prefered_buffer_size;
    }
}

Demo验证:

缓存值阈值 内存峰值
50 1.3G
20 530M
10 269M

方案结论: 该方案修改少,性能影响小,且稳定性可控

  1. 最终方案

通过的上面的分析,由于方案四“修改缓存池阈值”修改少,性能影响小,且稳定性可控 最终我们决定选用该方案。

  1. 修复效果

开启修复实验后相关机型OOM崩溃率显著下降近50% ,观察数周之后各项业务指标也均为正向,符合预期。全量上线后大盘renderD128相关OOM也大幅下降,另外renderD128导致发版熔断的情况从此再也没有发生过。

  1. 总结

在分析内存问题时,不论是系统申请的内存还是业务申请的内存,都需要明确申请逻辑和释放逻辑,才能确定是否发生泄漏还是长期不释放,再从内存申请和释放逻辑中寻找可优化点。

相关资料

  1. 华为畅享e内核源代码链接:consumer.huawei.com/en/opensour…
  2. mesa源代码链接:gitlab.freedesktop.org/mesa/mesa

MySQL 遇到 AI:字节跳动开源 MySQL 虚拟索引 VIDEX

虚拟索引技术(virtual index,也称为 hypothetical index)在数据库系统的查询优化、索引推荐等场景中扮演着关键角色。简单来说,虚拟索引可以理解为数据库的'沙盘推演'系统——无需真实构建索引,仅基于统计信息即可精准模拟不同索引方案对查询计划的优化效果。由于虚拟索引的创建/删除代价极低,使用者可以大量创建和删除索引、反复推演,确定最有效的索引方案。 AI 时代,基于 机器学习模型 NDV 、Cardinality 估计算法层出不穷,但是在 MySQL 落地往往遇到很大挑战:无法 在 MySQL 生成查询计划时 注入机器学习模型的 预测值 优化器 便难以给出更合适的索引推荐结果。

业界许多数据库已经以官方或第三方的方式提供了虚拟索引功能,例如 PostgresOracleIBM DB2。大量数据库领域的研究都围绕虚拟索引技术展开。遗憾的是,MySQL 长期缺乏这一能力,导致其在复杂场景下的优化效果始终受限。

经过长期的生产验证,字节跳动正式开源了 MySQL 虚拟索引项目 VIDEXVirtual Index ),让 MySQL 也有了自己的虚拟索引机制 🎉

VIDEX 开源地址:github.com/bytedance/v…

VIDEX 已经部署在了字节跳动大规模生产系统中,每天为数千用户、数十万慢 SQL 提供优化服务。VIDEX 的实用价值、工业级部署设计等特点,引来 Daniel Black(MariaDB Foundation 首席创新官)和 Federico Razzoli (Founder of Vettabase) 等业界知名专家的点赞与认可。

VIDEX 提供开箱即用的虚拟索引能力,可无缝集成至现有 MySQL 生态;对于数据库研究者,VIDEX 模块化设计允许新算法(如 NDV 估计、Cardinality 估计等等)在 MySQL 上快速验证,推动前沿技术落地。

具体来说,VIDEX 的贡献如下:

  1. 弥补 MySQL 虚拟 索引 空白:尽管业界已经有多种数据库支持了虚拟索引功能( PostgresOracleIBM DB2),也有一些论文和博客提到了 MySQL 的虚拟索引技术 [1,2],但据我们所知,VIDEX 是首个开源的、可拓展、支持多形态部署的 MySQL 虚拟索引解决方案。

  2. 精度 地拟合 MySQL:我们已经在 TPC-HTPC-H-SkewJOB 等复杂分析基准测试上对 VIDEX 进行了测试。给定准确的独立值估计(ndv) 和基数估计(Cardinality) 信息,VIDEX 可以 100% 模拟 MySQL InnoDB 的查询计划

  3. 基于分离架构的多形态部署:VIDEX 实现了数据库实例-VIDEX 优化器插件-VIDEX 算法服务的模块分离。既支持作为插件无缝集成到现有 MySQL 实例,也可作为独立服务构建虚拟库环境,实现生产环境零干扰的索引验证;额外地,将 VIDEX 优化器插件和 VIDEX 算法服务也做了分离,便于 AI 算法服务的集成和热更新。

  4. 可拓展的实验平台:准确地模拟 MySQL 查询代价依赖于对独立值(ndv)和基数(Cardinality)的准确估计——这正是 ****AI + 数据库研究中最火热的方向之一 [3]。VIDEX 给出了标准化、清晰易懂的接口设计,屏蔽了复杂的系统细节。研究者可以自由地用各种语言来重写 VIDEX 的算法模型,甚至只需要改动一个 JSON 文件,就能将自己的新算法应用于 MySQL 查询优化器!

多形态部署:从实验平台到生产环境

由于 VIDEX 将真实数据库实例、虚拟数据库实例、算法服务器三个部分解耦了,因此可以灵活应用于各种适用场景,从个人研究到生产环境部署:

VIDEX-Optimizer 的两种形态:

  1. 作为插件安装到真实数据库:将 VIDEX 作为插件安装到真实数据库实例,这样只需要一台 MySQL 实例,即可体验基于虚拟索引的各种 what-if 分析。适合于个人实验和分析。
  2. 以独立 实例 启动:独立启动 VIDEX 示例,同步统计信息,然后开始分析。此模式可以完全避免影响在线运行实例的稳定性,在工业环境中很实用。

VIDEX 算法服务器的两种形态:

  1. 与 VIDEX-Optimizer 配套启动:最经典的方式,无须额外设置,VIDEX-Optimizer 会自动寻找本地启动的 VIDEX 算法服务器。
  2. 独立启动算法服务器:只要设置一下 SQL 环境变量(SET @``VIDEX_STATISTIC_SERVER``='ip:port'),VIDEX-Optimizer 会将算法请求转发到指定的算法服务器上。对于研究者来说,可以自由实现算法、启动自定义的算法服务;对于云原生场景,可以将大量 MySQL 实例的算法请求发往中心式的算法服务,便于运维和快速更新。

VIDEX 任务的两种形态:

  1. 非任务模式:默认情况下,用户不需要关注 “task_id” —— 只需要指定目标库、指定虚拟库,同步数据即可;

  2. 任务模式:在大规模分析任务中(例如大规模索引推荐任务),各种用户往往会对同一个生产库的不同表、或不同实例的同名表发起多次分析。这种情况下,用户可以指定任务 id(SET @VIDEX_OPTIONS={'task_id': 'abc'}),让多个任务彼此互不影响。

算法试验场:把算法模型接入 MySQL 优化器

MySQL 采用了分离式的架构,上层的查询优化器会向下层存储引擎请求各种信息,包括元数据信息(table_rows、data_length 等等)、独立值(ndv)、基数(cardinality)、索引内存加载率等等。其中基数估计和独立值估计是 AI for DB 研究领域的热点方向。现已有大量 data-driven 或者 query-driven 的算法被提出,但这些算法往往只能以 PostgreSQL 作为试验场。

VIDEX 让用户不必与 MySQL 查询优化器做交互、也屏蔽了 MySQL 对库表元数据信息(table_rows、deta_length)的请求。由此,用户可以专注于一些重点的算法问题,例如 NDV 估计和 Cardinality 估计。

方法 1:在 VIDEX-Statistic-Server 中添加一种新方法

考虑到许多研究者习惯于用 Python 研究各种 AI 与 DB 结合的算法,因此,我们用 Python 实现了 VIDEX-Statistic。

用户可以继承并修改 VidexModelInnoDBVidexModelInnoDB 为用户屏蔽了系统变量、索引元数据格式等复杂细节,并提供了一个基于独立、均匀分布假设的 ndv 和 cardinality 算法。这样用户可以聚焦于 cardinality 和 ndv 这两个研究热点:

class VidexModelBase(ABC):
    """
    Abstract cost model class. VIDEX-Statistic-Server receives requests from VIDEX-Optimizer for Cardinality
    and NDV estimates, parses them into structured data for ease use of developers.

    Implement these methods to inject Cardinality and NDV algorithms into MySQL.
    """

    @abstractmethod
    def cardinality(self, idx_range_cond: IndexRangeCond) -> int:
        """
        Estimates the cardinality (number of rows matching a criteria) for a given index range condition.

        Parameters:
            idx_range_cond (IndexRangeCond): Condition object representing the index range.

        Returns:
            int: Estimated number of rows that match the condition.

        Example:
            where c1 = 3 and c2 < 3 and c2 > 1, ranges = [RangeCond(c1 = 3), RangeCond(c2 < 3 and c2 > 1)]
        """
        pass

    @abstractmethod
    def ndv(self, index_name: str, table_name: str, column_list: List[str]) -> int:
        """
        Estimates the number of distinct values (NDV) for specified fields within an index.

        Parameters:
            index_name (str): Name of the index.
            table_name (str): Table Name
            column_list (List[str]): List of columns(aka. fields) for which NDV is to be estimated.

        Returns:
            int: Estimated number of distinct values.

        Example:
            index_name = 'idx_videx_c1c2', table_name= 't1', field_list = ['c1', 'c2']
        """
        raise NotImplementedError()

假设用户用 VidexModelExample 重载了 VidexModelInnoDB ,可以指定模然后启动 VIDEX-Statistic-Server(详见代码启动脚本)。

startup_videx_server(VidexModelClass=VidexModelExample)

方法 2: 全新实现 VIDEX-Statistic-Server

用户可以用任何编程语言实现 HTTP 响应、并在任意位置启动 VIDEX-Statistic。

使用时,只需要指定环境变量(SET @VIDEX_STATISTIC_SERVER='ip:port'),VIDEX-Optimizer 就会将所有请求转发到指定服务上。

两步玩转 VIDEX,在 TPC-H 上看看效果

步骤 1: Docker 启动 VIDEX

最简单的情况下,用户可以用 Docker 启动一个安装好 VIDEX-Optimizer 和 VIDEX-Statistic 的容器。用户也可以参考文档说明,尝试其他启动方式。

为简化部署,我们提供了预编译的 Docker 镜像,包含:

  • VIDEX-Optimizer: 基于 Percona-MySQL 8.0.34-26,并集成了 VIDEX 插件
  • VIDEX-Statistic: ndv 和 cardinality 算法服务

如果您尚未安装 Docker:

docker run -d -p 13308:13308 -p 5001:5001 --name videx kangrongme/videx:0.0.2

步骤 2: VIDEX 数据准备

VIDEX 需要 Python 3.9 环境,执行元数据采集等任务。我们推荐使用 Anaconda/Miniconda 创建独立的 Python 环境来安装,详见 README 文档的 Quick Start 章节

git clone git@github.com:bytedance/videx.git videx_statistic
cd videx_statistic
python3.9 -m pip install -e . --use-pep517

指定原库和 VIDEX 库地址,用脚本一键式同步数据(以 tpch 为例):

python src/sub_platforms/sql_opt/videx/scripts/videx_build_env.py \
 --target 127.0.0.1:13308:tpch_tiny:videx:password \
 --videx 127.0.0.1:13308:videx_tpch_tiny:videx:password

效果展示:以 TPC-H 为例

本示例使用 TPC-H 数据集演示 VIDEX 的完整使用流程。

假设用户已经准备好了 TPCH 数据。篇幅限制,我们将更详细的步骤说明放到了 README 文档的 Example 章节

为了展示 VIDEX 的有效性,我们对比了 TPC-H Q21 的 EXPLAIN 细节,这是一个包含四表连接的复杂查询,涉及 WHERE聚合ORDER BYGROUP BYEXISTSSELF-JOIN 等多种部分。初始情况下,MySQL 可以选择的索引有 11 个,分布在 4 个表上。

EXPLAIN FORMAT = JSON
SELECT s_name, count(*) AS numwait
FROM supplier,
     lineitem l1,
     orders,
     nation
WHERE s_suppkey = l1.l_suppkey
  AND o_orderkey = l1.l_orderkey
  AND o_orderstatus = 'F'
  AND l1.l_receiptdate > l1.l_commitdate
  AND EXISTS (SELECT *
              FROM lineitem l2
              WHERE l2.l_orderkey = l1.l_orderkey
                AND l2.l_suppkey <> l1.l_suppkey)
  AND NOT EXISTS (SELECT *
                  FROM lineitem l3
                  WHERE l3.l_orderkey = l1.l_orderkey
                    AND l3.l_suppkey <> l1.l_suppkey
                    AND l3.l_receiptdate > l3.l_commitdate)
  AND s_nationkey = n_nationkey
  AND n_name = 'IRAQ'
GROUP BY s_name
ORDER BY numwait DESC, s_name;

让我们来对比 VIDEX 和 InnoDB 的估计效果。我们使用 EXPLAIN FORMAT=JSON,这是一种更加严格的格式。

我们不仅比较表连接顺序和索引选择,还包括查询计划的每一个细节(例如每一步的行数和代价)。

如下图所示,VIDEX(左图)能生成一个与 InnoDB(右图)几乎 100% 相同的查询计划。

VIDEX 的一个重要作用是模拟索引代价。我们额外新增一个索引。VIDEX 增加索引的代价是 O(1) ,因为他并不需要在真实数据上创建索引:

-- 为 innodb 库创建索引
ALTER TABLE tpch_tiny.orders ADD INDEX idx_o_orderstatus (o_orderstatus);
-- 为 videx 创建索引
ALTER TABLE videx_tpch_tiny.orders ADD INDEX idx_o_orderstatus (o_orderstatus);

再次执行 EXPLAIN,我们看到 MySQL-InnoDB 和 VIDEX 的查询计划发产生了相同的变化,两个查询计划均采纳了新索引,并且查询计划的细节也非常接近。

VIDEX 的行数估计 (7404) 与 MySQL-InnoDB (7362) 相差约为 0.56%,这个误差来自于基数估计算法的误差。

深入解析 VIDEX 架构

如图展现了 VIDEX 的架构。总体来说,VIDEX 包含两个模块:

  • VIDEX-Optimizer-Plugin(简称 VIDEX-Optimizer) :VIDEX 的“前端”。可以作为插件安装到现有数据库,或者以一个独立的新实例启动。这一部分实现了 MySQL 查询优化器接口,并将其中一部分复杂的请求转发到 VIDEX-Statistic-Server。我们全面梳理了 MySQL handler 的超过 90 个接口函数,并实现与索引(Index)相关的接口。

  • VIDEX-Statistic-Server(简称 VIDEX-Statistic):VIDEX 的“后端”。基于收集到的统计信息(表行数、表大小、直方图等等)和集成的算法或模型,计算独立值(NDV) 和基数(Cardinality),并将结果返回给 VIDEX-Optimizer。

当用户指定了要分析(what-if analysis)的真实数据库之后,VIDEX 会在 VIDEX-Optimizer 上创建一个虚拟数据库。虚拟数据库与真实数据库的关系表结构完全一致,只是将 Engine 从 InnoDB 更换为 VIDEX。为了准确模拟目标数据库的查询代价,VIDEX 会调用脚本,从真实数据库采集必要的统计信息。上述过程都可以用我们提供好的脚本一键式完成。

用户也可以自定义地提供一份元数据文件、让脚本直接导入。元数据文件是 json 格式,包含了库表结构信息、统计信息(table_rows、单列 ndv 等等)、直方图信息,非常容易理解。

VIDEX-Statistic-Server 是 VIDEX 的算法服务器。我们已经提供了基于独立均匀假设的 ndv 和 cardinality 算法。研究者可以自由地使用 Python、或者其他语言来实现算法,我们已经封装好了清晰明了的接口。

上述环节完成后,你就可以在虚拟数据库上自由的创建和删除索引,然后使用 EXPLAIN 来获取“贴近真实”的查询计划了 🚀

作者团队

我们来自字节跳动的 ByteBrain 团队,我们致力于用 AI 技术,为各种基础架构与系统(数据库、云原生、大数据)优化降本、提质增效。

如果您有任何疑问,请随时通过电子邮件联系我们:

参考资料

  1. Meta: Yadav, Ritwik, Satyanarayana R. Valluri, and Mohamed Zaït. "AIM: A practical approach to automated index management for SQL databases." 2023 IEEE 39th International Conference on Data Engineering (ICDE) . IEEE, 2023.
  2. Meituan: Slow Query Optimized Ddvice Driven by Cost Model: tech.meituan.com/2022/04/21/…
  3. Kossmann, J., Halfpap, S., Jankrift, M., & Schlosser, R. (2020). Magic mirror in my hand, which is the best in the land? an experimental evaluation of index selection algorithms. Proceedings of the VLDB Endowment, 13(12), 2382-2395.

字节跳动开源 Godel-Rescheduler:适用于云原生系统的全局最优重调度框架

背景

在云原生调度中,一次调度往往无法解决所有问题,需要配合重调度来优化资源分配和任务摆放。传统的重调度框架主要集中在识别异常节点或任务,并通过迁移或删除来解决。然而,这些框架往往只能解决局部问题,无法提供全局最优的调度方案,且容易出现多重调度策略冲突,影响整体稳定性。

为了解决这些问题,字节跳动研发了 Godel-Rescheduler,一个基于全局最优调度策略的重调度框架。它不仅能识别集群中的异常节点和任务,还能智能推荐任务到最合适的位置,并通过图算法生成详细的迁移步骤,确保集群的整体稳定性,真正实现全局最优调度。

项目简介

Godel-Rescheduler 由两个核心模块组成:Policy Manager 和 Movement Manager。其中,Policy Manager 负责输出重调度决策,而 Movement Manager 则负责拆解并执行这些决策。整个框架的目标是通过重调度,使集群朝向全局最优状态发展。

> 架构概览

> 组件介绍

Policy Manager 作为算法与策略控制中心,Policy Manager 负责配置重调度策略、迁移条件检测和执行相应的算法。它输出全局或局部最优的调度结果,并将决策传递给 Movement Manager。

  • Policy Controller 负责整体调度流程的控制,利用各子模块提供的功能,输出最优的调度决策。

  • Policy Configurator 负责读取并解析配置文件,定义重调度策略的触发条件、参数和作用范围。支持四种触发方式:周期执行、Signal 信号、HTTP 请求和 Cronjob。每个策略都可以根据需要配置不同的触发方式。

  • Detector 用于检测集群、机器和实例的状态,评估是否需要进行局部迁移或全局重调度。不同的策略可以通过定制化 Detector 实现不同的检测逻辑,如热点检测、负载均衡或碎片整理等。

  • Algorithm Provider 根据 Detector 提供的输入,算法插件为每个需要重调度的实例找到最适合的目标节点。为确保调度决策的有效性,算法会进行目标节点的校验,并与其他策略的决策进行冲突检查。

  • Movement Checker 校验一个迁移是否会对集群稳定性造成负面影响,特别是对目标节点的资源消耗、负载情况等进行验证。

  • Validator 在框架层面对每个移动决策进行最终校验,确保未经过校验的错误决策不会被执行。

Movement Manager 负责决策的执行和排序,并将新的调度结果上报,同时清除过期的推荐结果。

  • Movement Generator 算法基于有向图强连通分量分解,依据实例在节点间的转移关系和 PDB(PodDisruptionBudget)限制,生成优化的迁移批次。目标是尽可能减少迁移次数,同时保证集群稳定性。

  • Task Killer 负责按顺序执行每个移动决策,确保每个决策在保证稳定性的前提下逐步实施。

  • Movement Recycler 确保在新策略生成前,及时清除旧的调度决策,避免过期决策影响新的调度计划。

> 字节跳动的实际落地

目前,字节跳动已经成功将 Godel-Rescheduler 应用到多个内部项目中,支持多种重调度策略的协同工作。例如:

  • 合并部署重调度:优化上下游应用实例在相同节点上的调度。

  • 负载均衡重调度:在负载、内存带宽、网络带宽等方面进行优化。

  • 碎片整理重调度:有效减少 CPU、GPU 等资源的碎片率等。

在实际应用中,Godel-Rescheduler 已帮助字节跳动的数万卡 GPU 集群将碎片率控制在 5% 以下,同时在大规模混合部署集群中,热点节点比例控制在 0.1% 以下,取得了显著的效果。

未来发展

未来,Godel-Rescheduler 将持续扩展和优化:

  • 更多重调度策略:引入更多实时数据,以丰富调度策略的多样性。

  • 稳定性建设:在优化调度效果的同时,持续降低重调度对集群稳定性的影响。

  • 扩展性优化:进一步简化策略接入方式,提升插件化能力。

  • 通用指标构建:制定通用的重调度评价指标,以全面评估调度效果。

  • 优化可解释性:增强重调度算法的可解释性,帮助用户更好地理解调度决策的依据。

相关链接

Godel ReScheduler 项目地址:

github.com/kubewharf/g…

Multi-SWE-bench:首个多语言代码修复基准开源

字节跳动豆包大模型团队正式开源首个多语言类 SWE 数据集——Multi-SWE-bench,可用于评估和提升大模型 “自动修 Bug” 能力。

在 SWE-bench 基础上,Multi-SWE-bench 首次覆盖 Python 之外的 7 种主流编程语言,是真正面向 “全栈工程” 的评测基准。其数据均来自 GitHub issue,历时近一年构建,以尽可能准确测评和提高大模型高阶编程智能水平。

本文将介绍 Multi-SWE-bench 的研究背景、数据集构建及后续计划,希望与业界共同促使代码生成技术走向成熟。

从 ChatGPT 到 4o、o1、o3、Claude-3.5/3.7,再到 Doubao-1.5-pro、DeepSeek-R1,大模型正在以惊人速度革新编码世界。

如今,AI 早已不限于写函数、查 API,让 AI 自动解决 GitHub 上提交的真实问题(Bug),也已成为衡量模型智能高低的标杆之一。

但问题也随之浮现:现有主流评测数据集,如 SWE-bench,全部是 Python 项目。这导致部分大模型在 Python 榜单上分数较高,但并不擅长其他语言。

为解决泛化能力不足的问题,字节跳动豆包大模型团队正式开源 Multi-SWE-bench。

该数据集是业内首个面向多语言代码问题修复的大模型评测基准,覆盖 Java、TypeScript、C、C++、Go、Rust 和 JavaScript 等编程语言。

作为一个标准化、可复现、覆盖多语言的 “自动编程” 开源评测基准,Multi-SWE-bench 旨在推动自动编程技术从仅能解决单一语言(如 Python)和低复杂度的任务,朝着支持多语言、具备真实问题解决能力的通用型智能体迈进。

伴随强化学习崛起,团队还同步开源了 Multi-SWE-RL,为 RL 在真实代码环境中的训练提供了标准化、可复用的数据基础设施。

目前 Multi-SWE-bench 论文、代码和数据集已全部公开。

团队认为,此次开源不过迈出了千里之行的小小一步,单靠一个团队远远无法满足技术发展所需,在此欢迎更多研究者共同参与开源基准和数据基础设施建设。

Multi-SWE-bench: A Multilingual Benchmark for Issue Resolving

论文链接:arxiv.org/abs/2504.02…

榜单链接:multi-swe-bench.github.io

代码链接:github.com/multi-swe-b…

数据链接:huggingface.co/datasets/By…

 1. 主流代码基准局限:覆盖语言单一、复杂任务有限 

代码生成任务对大语言模型的逻辑推理、上下文理解等核心能力提出了综合要求。相应地,SWE-bench 为代表的代码修复基准,近年来已成为衡量模型智能水平的重要指标。

SWE-bench 是当前最具代表性的代码修复评测基准,强调任务真实、难度高。它基于 GitHub issue,要求模型自动定位并修复 Bug,兼具跨文件修改、复杂语义推理与上下文理解等挑战。相比传统代码生成任务(例如 HumanEval、MBPP、LiveCodeBench),SWE-bench 更贴近现实开发场景,是衡量大模型高阶 “编程智能” 的关键标尺。

然而,随着行业快速发展与模型能力不断提升,该基准难以全面覆盖现实开发中的多语言环境与复杂任务,制约大模型代码智能进一步演进。

具体看,其局限主要体现在以下两方面:

(1)语言维度单一:当前主流评测几乎全部集中在 Python,缺乏其他语言覆盖,难以评估模型的跨语言泛化能力。

(2)任务难度不足:现有基准多为短 patch、单文件修复,未覆盖超多文件、多步骤、超长上下文等复杂开发场景。同时,SWE-bench 中的任务未做难度分级,难以系统衡量模型在不同能力层次上的表现。

在此背景下,行业迫切需要一个覆盖主流编程语言、具备高质量标注实例与难度分级的 “多语言 Bug 修复评测集”。

 2. 涵盖 7 种语言、1,632 个真实修复任务的 Multi-SWE-bench 

Multi-SWE-bench 旨在补全现有同类基准语言覆盖方面的不足,系统性评估大模型在复杂开发环境下的 “多语言泛化能力”,推动多语言软件开发 Agent 的评估与研究,其主要特性如下:

  • 首次覆盖 7 种主流编程语言(包括 Java、Go、Rust、C、C++、TypeScript、JavaScript),构建多语言开发环境下的代码修复任务,系统评估模型的跨语言适应与泛化能力;

  • 引入任务难度分级机制,将问题划分为简单(Easy)、中等(Medium)和困难(Hard)三类,涵盖从一行修改到多文件、多步骤、多语义依赖的开发挑战;

  • 1,632 个实例全部来源于真实开源仓库,并经过统一的测试标准和专业开发者的审核筛选,确保每个样本具备清晰的问题描述、正确的修复补丁以及可复现的运行测试环境。

面向不同模型代码能力评测分数

团队基于 Multi-SWE-bench 进行实验观察到,尽管当前 LLM 面向 Python 修复率表现不错,但面向其他语言平均修复率普遍不足 10%。

部分主流模型在 Python 上表现更为优异,面向其他语言则分数不佳。同时,随着任务难度增加,模型修复率呈现逐级下降趋势。

这也说明,多语言代码修复仍是大模型智能能力的一道分水岭,更是推动 AI 向通用编程智能体演进的核心方向。

 3. 历时近一年系统性构建,引入严格人工验证 

在构建 Multi-SWE-bench 过程中,团队设计并执行了一套系统性的数据构建流程,共分五个阶段,涵盖从项目筛选、数据采集到数据验证的全过程,最大程度保障数据的真实性、全面性与可用性。

Multi-SWE-bench 构建流程

第一步:开源仓库筛选

团队基于 GitHub 公开仓库,从多个维度筛选高质量的项目仓库,确保覆盖 7 大主流语言(Java、TypeScript、JavaScript、Go、Rust、C 和 C++)。选择标准包括: 

(1)超过 500 GitHub Stars,具备一定的社区活跃度;

(2)至少半年被持续维护; 

(3)拥有 CI/CD 支持,可通过 GitHub Actions 等工具自动化构建与测试;

(4)构建过程可复现,确保后续环境搭建无阻。

第二步:Pull Request(PR)爬取

完成仓库初筛后,团队通过自动化爬虫从项目中收集所有 PR,并应用以下过滤规则进行筛选:

(1)PR 必须关联至少一个 GitHub issue;

(2)包含测试文件的修改,确保修复行为可验证;

(3)已被合并至主分支,代码质量与维护者被充分认可。

其中,每条 PR 记录都会提取关键信息,包含:原始 issue 描述、修复补丁、测试补丁、commit 信息等。

第三步:构建可执行的 Docker 环境

为了保证数据集中的每个任务都具备完整的可运行性,团队基于每个 PR,构建了对应的 Docker 容器,复刻其运行环境。 

依托 CI/CD 配置、README 等元信息,提取依赖项并自动生成 Dockerfile。对于构建失败的情况,团队还会手动排查错误并尽可能修复,确保环境的完整性和可复现性。

第四步:PR 过滤和数据集制作

每个 PR 会在构建好的环境中依次运行三种状态的测试流程: 

(1)原始状态(未打任何 patch) ;

(2)仅应用测试补丁(test.patch) ;

(3)同时应用测试与修复补丁(test.patch + fix.patch) ;

团队通过分析三阶段测试日志,识别是否存在有效的修复行为(如 FAILED→PASSED),并排除存在回归风险、测试行为异常等不符合规范的样本。经过这一阶段后,团队最终保留了 2,456 条候选数据。

第五步:严格的人工验证机制

为进一步提升数据可靠性,团队引入了人工双重标注流程。共计 68 位专业标注者参与审核,所有标注人员具备对应语言开发经验与高度相关的背景。 

每条样本由两位独立标注员标注,并进行交叉复核,最终所有标注结果还需通过内部 QA 团队的抽检,确保一致性与准确性。

经过这一阶段,我们最终保留了 1,632 条高质量的实例,并公开所有标注问卷与评分数据,确保数据透明度。

通过系统化的数据构建流程,团队希望为未来自动编程智能体的评估与训练打下坚实基础,驱动相关研究走向规模化与工程化。

 4. Multi-SWE-RL 开源 & 社区招募 

随着 GPT-4o、o1、o3 等新一代模型的爆火,强化学习方法在自动编程中的潜力正受到广泛关注。 基于 RL 将对代码智能体起到重要推动作用的判断,豆包大模型团队进一步构建了 Multi-SWE-RL,为 RL 在代码环境中的训练,提供统一、标准的数据基础。这让模型不仅有了学习的 “教材”,还拥有学习的 “环境”。

作为首批贡献者,团队初步贡献了 4,723 个实例,每个实例均配备可复现的 Docker 环境,并且支持一键启动、自动评估、快速接入 RL 训练框架。同时,团队完整开源数据构建流程与工具链。

目前,团队同步启动开源社区计划,鼓励开发者共同参与数据集扩展、RL 数据贡献与新方法评测。Multi-SWE-RL 项目提供了详尽的贡献教程、激励机制,以及实时更新的任务看板,确保社区协作高效透明。所有新增数据与评测结果将定期纳入后续公开版本,并署名所有有效贡献者或作者。

豆包大模型团队期待与更多开发者、研究者共同推进 RL for Code 生态建设,为构建通用软件智能体奠定基础。

数据集链接:huggingface.co/datasets/By…

 5. 写在最后 

豆包大模型团队希望,Multi-SWE-bench 能作为大模型在多种主流编程语言与真实代码环境中的系统性评测基准,推动自动编程能力向更实用、更工程化的方向发展。

相比于以往聚焦 Python 的单语言任务,Multi-SWE-bench 更贴近现实中的多语言开发场景,也更能反映当前模型在 “自动化软件工程” 方向上的实际能力边界。

在未来,团队将持续拓展 Multi-SWE 系列的覆盖范围——包括新增语言、扩展更多软工任务,并通过社区共建机制,鼓励更多研究者和开发者参与 基准构建与 RL 训练数据贡献。

DeepSeek + Function Call:基于 Eino 的“计划——执行”多智能体范式实战

DeepSeek-R1(以下简称 DeepSeek)以其优秀的复杂问题推理能力和规划能力脱颖而出,然而其原生函数调用(Function Call)功能的缺失,无法让大模型去选择不同的工具和程序,以获取对应的信息,使其难以完成以下关键动作:

  • 实时数据获取(天气 / 票务 / 交通)

  • 外部服务交互(地图 API / 支付接口)

  • 复杂任务拆解执行(多步骤自动化)

这就导致它的应用场景受到限制,大多只能用于简单的对话式问答。有没有一个解决办法,能实现让 DeepSeek 做 Function Call?

答案是肯定的,我们提出 " 计划——执行” 多智能体的协同范式

由 DeepSeek 负责 “指挥”,由擅长 Function Call 的其他大模型去听指挥进行函数调用。这需要利用“计划——执行” 多智能体范式,由 “计划” 智能体负责推理和生成计划,由 “执行” 智能体负责执行计划:

“计划——执行” 多智能体范式的三大优势:

  1. 专业的 “智能体” 干专业的事情:比如 DeepSeek 负责推理和计划,豆包大模型负责 Function Call。

  2. “智能体” 层面的单一职责原则:每个智能体的职责是明确的,解耦的,方便 Prompt 调优和评测。

  3. 在提供解决问题整体方案的同时,保持灵活性:符合人类解决问题的通用模式。

要实现 “计划 —— 执行” 多智能体,我们必须要解决几个问题:多模型、多工具集成,复杂流程编排,上下文管理以及中间步骤追踪。Eino(文档 cloudwego.io/zh/docs/ein… 项目页 github.com/cloudwego/e… 框架通过提供开箱即用的模型组件实现和工具执行器、面向抽象接口的灵活流程编排能力、完备的全局状态管理以及回调机制,确保了上述问题的有效解决。

接下来,文章将直观的解释 “计划 —— 执行” 多智能体范式,介绍如何借助 Eino 框架来实现基于 DeepSeek 的‘计划 —— 执行’多智能体,最后通过一个有趣且靠谱的主题乐园行程规划助手的实战案例,带大家从 0 到 1 搭建一个完整的应用。

“计划——执行” 多智能体

基本的 ReAct 单智能体,是由一个 Agent 既负责计划拆解,也负责 Function Call:

可能存在的问题有三个:

  1. 对 LLM 的要求高:既要擅长推理规划,也要擅长做 Function Call。

  2. LLM 的 prompt 复杂:既要能正确规划,又要正确的做 Function Call,还要能输出正确的结果。

  3. 没有计划:每次 Function Call 之后,LLM 需要重新推理,没有整体的可靠计划。

解决的思路,首先是把单个的 LLM 节点拆分成两个,一个负责 “计划”,一个负责 “执行”:

这样就解决了上面的问题 3,Planner 会给出完整计划,Executor 依据这个完整计划来依次执行。部分解决了问题 1、2,Planner 只需要擅长推理规划,Executor 则需要擅长做 Function Call 和总结,各自的 prompt 都是原先的一个子集。但同时带来一个新的问题:

  1. 缺少纠错能力:最开始的计划,在执行后,是否真的符合预期、能够解决问题?

继续优化多智能体结构,在 Executor 后面增加一个 LLM 节点,负责 “反思和调整计划”:

这样就彻底解决了上面列出的问题,Executor 只需要按计划执行 Function Call,Reviser 负责反思和总结。

这就是 “计划——执行” 多智能体:通过将任务解决过程拆解为负责计划的 Planner 和 Reviser,以及负责执行的 Executor,实现了智能体的单一职责以及任务的有效计划与反思,同时也能够充分发挥 DeepSeek 这种推理模型的长项、规避其短板(Function Call)。

基于 Eino 框架实现 “计划——

执****行” 多智能体

实现一个 “计划——执行” 多智能体,需要:

  • 能够快速简单的集成 DeepSeek、豆包等各种大模型。

  • 能够快速简单的集成和执行各种 Tool。

  • 能够快速实现流程编排,把多个智能体以及工具按设计的流程串联起来,并能随时快速调整。

  • 能够及时的输出各智能体的执行过程,包括 DeepSeek 的推理过程。

  • 能够有效的管理和传递上下文。

Eino 是字节跳动开源的基于 Golang 的大模型应用开发框架,已在豆包、抖音、扣子等多个业务线广泛使用。我们选择 Eino 作为框架来进行全码开发,因为:

  • Eino 可以用几行代码完成对各种大模型的调用,包括 DeepSeek。

  • Eino 可以用几行代码快速把一个本地 Function 封装成 Tool,且有开箱即用的 Tool 执行器。

  • Eino 的流程编排能力可靠且灵活:分支判断,循环,运行时参数配置等。

  • Eino 的数据流处理能力为大模型应用场景而设计,可配合完整的回调机制实时输出中间结果。

  • Eino 可以通过在图编排时配置和读写全局状态来实现有效的上下文管理和传递。

Eino 的详细信息参见:文档 cloudwego.io/zh/docs/ein…

GitHub 项目页 github.com/cloudwego/e…

实战:主题乐园行程规划助手

我们通过实现一个主题乐园行程规划助手,来探索如何用 Eino 实现基于 DeepSeek 的 “计划——执行” 多智能体。这个多智能体的功能是根据用户的游园需求,规划出具体、符合要求、可操作的行程安排。完整代码仓库地址:github.com/cloudwego/e…

定义多智能体

首先定义多智能体以及需要的配置:

package plan_execute
import (
    "github.com/cloudwego/eino/components/model"
    "github.com/cloudwego/eino/compose"
    "github.com/cloudwego/eino/schema"
)
// Config “计划——执行”多智能体的配置.
type Config struct {
    PlannerModel        model.ChatModel // planner 智能体使用的大模型
    PlannerSystemPrompt string          // planner 智能体的 system prompt
    ExecutorModel        model.ChatModel         // executor 智能体使用的大模型
    ToolsConfig          compose.ToolsNodeConfig // executor 智能体使用的工具执行器配置
    ExecutorSystemPrompt string                  // executor 智能体的 system prompt
    ReviserModel        model.ChatModel // reviser 智能体使用的大模型
    ReviserSystemPrompt string          // reviser 智能体的 system prompt
    MaxStep int // 多智能体的最大执行步骤数,避免无限循环
}
// PlanExecuteMultiAgent “计划——执行”多智能体.
type PlanExecuteMultiAgent struct {
    // 图编排后的可执行体,输入是 Message 数组,输出是单条 Message
    runnable compose.Runnable[[]*schema.Message, *schema.Message]
}

多智能体编排逻辑

Eino 的流程编排有 “节点(Node)”、“边(Edge)” 和“分支 (Branch)” 组成,数据流转时要求严格的类型对齐。完整的数据流转图如下:

上图中,Planner,Executor,Reviser 都是输入为 []*Message,输出为 * Message 的 ChatModel 节点,Branch1 判断 Executor 是否完成了本轮次所有的 Function Call,Branch2 判断 Reviser 是否输出了最终答案,各个 ToList 节点负责连接两个 ChatModel,将输出的 *Message 转化为 []*Message,从而满足类型校验要求。

我们实现一个 NewMultiAgent 方法来实现上述编排逻辑:

// NewMultiAgent 根据配置编排一个“计划——执行”多智能体.
func NewMultiAgent(ctx context.Context, config *Config) (*PlanExecuteMultiAgent, error) {
    var (
       toolInfos      []*schema.ToolInfo
       toolsNode      *compose.ToolsNode
       err            error
       plannerPrompt  = config.PlannerSystemPrompt
       executorPrompt = config.ExecutorSystemPrompt
       reviserPrompt  = config.ReviserSystemPrompt
       maxStep        = config.MaxStep
    )
    if len(plannerPrompt) == 0 {
       plannerPrompt = defaultPlannerPrompt
    }
    if len(executorPrompt) == 0 {
       executorPrompt = defaultExecutorPrompt
    }
    if len(reviserPrompt) == 0 {
       reviserPrompt = defaultReviserPrompt
    }
    if maxStep == 0 {
       maxStep = defaultMaxStep
    }
    if toolInfos, err = genToolInfos(ctx, config.ToolsConfig); err != nil {
       return nil, err
    }
    // 为 Executor 配置工具
    if err = config.ExecutorModel.BindTools(toolInfos); err != nil {
       return nil, err
    }
    // 初始化 Tool 执行器节点,传入可执行的工具
    if toolsNode, err = compose.NewToolNode(ctx, &config.ToolsConfig); err != nil {
       return nil, err
    }
    // 创建一个待编排的 graph,规定整体的输入输出类型
    graph := compose.NewGraph[[]*schema.Message, *schema.Message]()
    // 定义 Executor 后的分支判断用的条件函数。该函数的输出是运行时选中的 NodeKey
    executorPostBranchCondition := func(_ context.Context, msg *schema.Message) (endNode string, err error) {
        if len(msg.ToolCalls) == 0 {
           return nodeKeyExecutorToList, nil
        }
        return nodeKeyTools, nil
    }
    // 定义 Reviser 后的分支判断用的条件函数。
    reviserPostBranchCondition := func(_ context.Context, sr *schema.StreamReader[*schema.Message]) (endNode string, err error) {
       defer sr.Close()
       var content string
       for {
          msg, err := sr.Recv()
          if err != nil {
             if err == io.EOF {
                return nodeKeyReviserToList, nil
             }
             return "", err
          }
          content += msg.Content
          if strings.Contains(content, "最终答案") {
             return compose.END, nil
          }
          if len(content) > 20 {
             return nodeKeyReviserToList, nil
          }
       }
    }
    // 添加 Planner 节点
    _ = graph.AddChatModelNode(nodeKeyPlanner, config.PlannerModel, compose.WithNodeName(nodeKeyPlanner))
    // 添加 Executor 节点
    _ = graph.AddChatModelNode(nodeKeyExecutor, config.ExecutorModel, compose.WithNodeName(nodeKeyExecutor))
    // 添加 Reviser 节点
    _ = graph.AddChatModelNode(nodeKeyReviser, config.ReviserModel, compose.WithNodeName(nodeKeyReviser))
    // 添加 Tool 执行器节点
    _ = graph.AddToolsNode(nodeKeyTools, toolsNode)
    // 添加三个 ToList 转换节点
    _ = graph.AddLambdaNode(nodeKeyPlannerToList, compose.ToList[*schema.Message]())
    _ = graph.AddLambdaNode(nodeKeyExecutorToList, compose.ToList[*schema.Message]())
    _ = graph.AddLambdaNode(nodeKeyReviserToList, compose.ToList[*schema.Message]())
    // 添加节点之间的边和分支
    _ = graph.AddEdge(compose.START, nodeKeyPlanner)
    _ = graph.AddEdge(nodeKeyPlanner, nodeKeyPlannerToList)
    _ = graph.AddEdge(nodeKeyPlannerToList, nodeKeyExecutor)
    _ = graph.AddBranch(nodeKeyExecutor, compose.NewStreamGraphBranch(executorPostBranchCondition, map[string]bool{
       nodeKeyTools:          true,
       nodeKeyExecutorToList: true,
    }))
    _ = graph.AddEdge(nodeKeyTools, nodeKeyExecutor)
    _ = graph.AddEdge(nodeKeyExecutorToList, nodeKeyReviser)
    _ = graph.AddBranch(nodeKeyReviser, compose.NewStreamGraphBranch(reviserPostBranchCondition, map[string]bool{
       nodeKeyReviserToList: true,
       compose.END:          true,
    }))
    _ = graph.AddEdge(nodeKeyReviserToList, nodeKeyExecutor)
    // 编译 graph,将节点、边、分支转化为面向运行时的结构。由于 graph 中存在环,使用 AnyPredecessor 模式,同时设置运行时最大步数。
    runnable, err := graph.Compile(ctx, compose.WithNodeTriggerMode(compose.AnyPredecessor), compose.WithMaxRunSteps(maxStep))
    if err != nil {
       return nil, err
    }
    return &PlanExecuteMultiAgent{
       runnable: runnable,
    }, nil
}

Tool 实现

我们的主题乐园行程规划助手,需要用到下列工具:

  • query_theme_park_opening_hour: 查询乐园 A 的整体营业时间

  • query_park_ticket_price: 查询乐园 A 的门票价格

  • list_locations: 列出乐园 A 中的所有区域,每个游乐设施都归属于一个区域

  • query_location_adjacency_info: 查询乐园 A 中的一个区域到其他相邻区域的步行时间,以分钟为单位

  • query_attraction_queue_time: 查询游乐设施的排队时间,以分钟为单位

  • query_attraction_info: 查询游乐设施的具体信息

  • query_performance_info: 查询演出的具体信息

  • query_restaurant_info: 查询餐厅的具体信息

  • validate_performance_time_table: 校验安排的表演场次是否符合事实

  • arrange_performances: 根据选中的表演名称,自动根据表演的时间表排程

  • validate_plan_items: 根据一个一日日程安排提案,校验各个计划项内部及之间是否自洽

首先定义核心的领域模型:

type ActivityType string
const (
    ActivityTypeAttraction  ActivityType = "attraction"
    ActivityTypePerformance ActivityType = "performance"
    ActivityTypeRestaurant  ActivityType = "restaurant"
    ActivityTypeOther       ActivityType = "other"
)
// Activity 主题乐园中的一个项目,可以是游乐设施、表演或餐厅.
type Activity struct {
    Name               string       `json:"name"`
    Desc               string       `json:"desc"`
    Type               ActivityType `json:"type"`
    Location           string       `json:"location" jsonschema:"description:项目所属的区域"`
    MinHeight          int          `json:"min_height,omitempty" jsonschema:"description:参加游乐设施需要的最小身高,单位是厘米。如果为空,则没有身高要求"`
    Duration           int          `json:"duration,omitempty" jsonschema:"description:一个项目参加一次需要的时间,注意不包括排队的时间。如果为空,则缺少具体的时间信息"`
    TimeTable          []string     `json:"time_table,omitempty" jsonschema:"description:一个演出的时间表。如果为空,则使用 OpenTime 和 CloseTime 来表示这个项目的运营时间范围"`
    OpenTime           string       `json:"open_time,omitempty" jsonschema:"description:一个项目开始运营的时间"`
    CloseTime          string       `json:"close_time,omitempty" jsonschema:"description:一个项目结束运营的时间"`
    RequireBooking     bool         `json:"require_booking,omitempty" jsonschema:"description:一个餐厅是否需要提前预约"`
    HasPriorityAccess  bool         `json:"has_priority_access,omitempty" jsonschema:"description:一个项目是否有高速票服务"`
    PriorityAccessCost int          `json:"priority_access_cost,omitempty" jsonschema:"description:一个项目如果有高速票服务,则一个人的高速票需要花多少钱"`
    QueueTime          int          `json:"queue_time,omitempty" jsonschema:"description:一个项目常规需要的排队时间,单位是分钟。如果为空,则这个项目一般不需要排队"`
}

注意大多数字段中都有 jsonschema:"description:xxx"go struct tag。Eino 框架可抽取这个信息以及其他的 tag 给到大模型。

实现工具列表中需要的本地 function,如:

// GetAttractionInfo 获取游乐设施信息.
func GetAttractionInfo(_ context.Context, in *ListAttractionRequest) (out *ListAttractionResponse, err error) {
    if len(in.Name) > 0 && in.Name != "all" {
       for _, a := range attractions {
          if a.Name == in.Name {
             return &ListAttractionResponse{
                Attractions: []Activity{
                   a,
                },
             }, nil
          }
       }
    }
    if len(in.Location) > 0 {
       locationAttractions := make([]Activity, 0)
       for _, a := range attractions {
          if a.Location == in.Location {
             locationAttractions = append(locationAttractions, a)
             return &ListAttractionResponse{
                Attractions: locationAttractions,
             }, nil
          }
       }
    }
    return &ListAttractionResponse{
       Attractions: attractions,
    }, nil
}

完整的领域模型及服务定义参见代码链接 (github.com/cloudwego/e…

数据来源:可以是主题乐园提供的 API,也可以是外置的数据库,在我们的场景中,直接在项目中维护结构化的信息(完整代码链接 github.com/cloudwego/e…

将本地 function 封装成 Tool:

func GetTools(ctx context.Context) (tools []tool.BaseTool, err error) {    queryTimeTool, err := utils.InferTool("query_theme_park_opening_hour", "查询乐园 A 的整体营业时间", GetParkHour)    if err != nil {       return nil, err    }    tools = append(tools, queryTimeTool)        // 以下省略多个 Tool        return}

完整的 Tool 封装代码参见代码链接 (github.com/cloudwego/e…

上下文管理

针对每个智能体的一次执行,它的上下文应当包括:

  • 用户输入的任务。

  • 之前执行的智能体(包括自身)的输出。

  • 之前执行的智能体的 Function Call,以及对应的结果。

  • 自身的 System Prompt。

为了保存多智能体的上下文,我们为 graph 增加全局状态,并在各智能体执行前以及 Tool 执行前,向这个全局状态中读写上下文:

// state 以多智能体一次运行为 scope 的全局状态,用于记录上下文
type state struct {
    messages            []*schema.Message
}
func NewMultiAgent(ctx context.Context, config *Config) (*PlanExecuteMultiAgent, error) {
    // ... 省略 N 行 ... 
    // 创建一个待编排的 graph,规定整体的输入输出类型,配置全局状态的初始化方法
    graph := compose.NewGraph[[]*schema.Message, *schema.Message](compose.WithGenLocalState(func(ctx context.Context) *state {
       return &state{}
    }))
    // 在大模型执行之前,向全局状态中保存上下文,并组装本次的上下文
modelPreHandle := func(systemPrompt string, isDeepSeek bool) compose.StatePreHandler[[]*schema.Message, *state] {
    return func(ctx context.Context, input []*schema.Message, state *state) ([]*schema.Message, error) {
       for _, msg := range input {
          state.messages = append(state.messages, msg)
       }
       if isDeepSeek {
          return append([]*schema.Message{schema.SystemMessage(systemPrompt)}, convertMessagesForDeepSeek(state.messages)...), nil
       }
       return append([]*schema.Message{schema.SystemMessage(systemPrompt)}, state.messages...), nil
    }
}
    // ... 省略 N 行 ...
    // 添加 Planner 节点,同时添加 StatePreHandler 读写上下文
    _ = graph.AddChatModelNode(nodeKeyPlanner, config.PlannerModel, compose.WithStatePreHandler(modelPreHandle(plannerPrompt, true)), compose.WithNodeName(nodeKeyPlanner))
    // 添加 Executor 节点,同时添加 StatePreHandler 读写上下文
    _ = graph.AddChatModelNode(nodeKeyExecutor, config.ExecutorModel, compose.WithStatePreHandler(modelPreHandle(executorPrompt, false)), compose.WithNodeName(nodeKeyExecutor))
    // 添加 Reviser 节点,同时添加 StatePreHandler 读写上下文
    _ = graph.AddChatModelNode(nodeKeyReviser, config.ReviserModel, compose.WithStatePreHandler(modelPreHandle(reviserPrompt, true)), compose.WithNodeName(nodeKeyReviser))
    // 添加 Tool 执行器节点,同时添加 StatePreHandler 读写上下文
    _ = graph.AddToolsNode(nodeKeyTools, toolsNode, compose.WithStatePreHandler(func(ctx context.Context, in *schema.Message, state *state) (*schema.Message, error) {
        state.messages = append(state.messages, in)
        return in, nil
    }))
    // ... 省略 N 行 ...
}

完整编排代码见链接 github.com/cloudwego/e…

main 函数:多智能体执行

多智能体执行逻辑需要实现下列功能:

  • 实例化 DeepSeek 和豆包的模型,并放到多智能体的配置中。

  • 获取 Tool 列表。

  • 依据配置编排和初始化多智能体。

  • 将多智能体的各中间步骤及时输出。

在 main 函数中:利用 Eino 框架提供的组件实现,实例化需要的大模型,获取 Tool,初始化多智能体:

func main() {
    ctx := context.Background()
    deepSeekModel, err := deepseek.NewChatModel(ctx, &deepseek.ChatModelConfig{
       Model:   os.Getenv("DEEPSEEK_MODEL_ID"),
       APIKey:  os.Getenv("DEEPSEEK_API_KEY"),
       BaseURL: os.Getenv("DEEPSEEK_BASE_URL"),
    })
    if err != nil {
       log.Fatalf("new DeepSeek model failed: %v", err)
    }
    arkModel, err := ark.NewChatModel(ctx, &ark.ChatModelConfig{
       APIKey: os.Getenv("ARK_API_KEY"),
       Model:  os.Getenv("ARK_MODEL_ID"),
    })
    if err != nil {
       log.Fatalf("new Ark model failed: %v", err)
    }
    toolsConfig, err := tools.GetTools(ctx)
    if err != nil {
       log.Fatalf("get tools config failed: %v", err)
    }
    // 创建多智能体的配置,system prompt 都用默认值
    config := &Config{
       // planner 在调试时大部分场景不需要真的去生成,可以用 mock 输出替代
       PlannerModel: &debug.ChatModelDebugDecorator{
          Model: deepSeekModel,
       },
       ExecutorModel: arkModel,
       ToolsConfig:   compose.ToolsNodeConfig{Tools: toolsConfig},
       ReviserModel: &debug.ChatModelDebugDecorator{
          Model: deepSeekModel,
       },
    }
    planExecuteAgent, err := NewMultiAgent(ctx, config)
    if err != nil {
       log.Fatalf("new plan execute multi agent failed: %v", err)
    }
    printer := newIntermediateOutputPrinter() // 创建一个中间结果打印器
    printer.printStream()                     // 开始异步输出到 console
    handler := printer.toCallbackHandler()    // 转化为 Eino 框架的 callback handler
    // 以流式方式调用多智能体,实际的 OutputStream 不再需要关注,因为所有输出都由 intermediateOutputPrinter 处理了
    _, err = planExecuteAgent.Stream(ctx, []*schema.Message{schema.UserMessage("我们一家三口去乐园玩,孩子身高 120 cm,园内预算 2000 元,最爱的是各种表演,游乐设施比较偏爱刺激项目,希望能在一天内尽可能多体验不同的活动,帮忙规划一个行程。我们会在园区开门之后立刻入园,在园区关闭之后再离开。")},
       agent.WithComposeOptions(compose.WithCallbacks(handler)), // 将中间结果打印的 callback handler 注入进来
       // 给 planner 指定 mock 输出
       //agent.WithComposeOptions(compose.WithChatModelOption(debug.WithDebugOutput(schema.AssistantMessage(debug.PlannerOutput, nil))).DesignateNode(nodeKeyPlanner)),
       // 给 reviser 指定 mock 输出
       //agent.WithComposeOptions(compose.WithChatModelOption(debug.WithDebugOutput(schema.AssistantMessage("最终答案", nil))).DesignateNode(nodeKeyReviser)),
    )
    if err != nil {
       log.Fatalf("stream error: %v", err)
    }
    printer.wait() // 等待所有输出都处理完再结束
}

完整 main 函数代码实现链接 github.com/cloudwego/e…

流式输出中间过程

在上面的 main 函数中可以看到,我们通过 printer 这个 “中间结果打印器”,把各智能体的流式输出异步打印了出来。这利用了 Eino 框架的 callback 机制,在 ChatModel 输出时和 Tool 起止时触发执行切面逻辑。

调试与优化

在上面的 main 函数中,通过 ChatModelDebugDecorator 把 Planner 智能体封装起来,这是因为调试过程中,我们经常需要固定 Planner 的输出,单独调试后续流程。在 Eino 框架中,能够很方便的实现类似的装饰者,因为所有的组件如 ChatModel 等都是 interface,从编排角度看,原始的 ChatModel 实现和对应的装饰者可以无缝替换。完整调试代码实现见链接 github.com/cloudwego/e…

如果你更习惯可视化调试,可以安装 Eino Dev IDE 插件 www.cloudwego.io/zh/docs/ein… Golang 和 VSCode),把多智能体的编排拓扑在 IDE 中直观的展示出来,还可以指定中间的某个智能体开始调试。

在实战过程中,对 system prompt 的优化占据了相当多的时间,有关优化的最佳实践还在总结之中,后续会在 Eino 项目中发布,可以关注我们的 Github 项目 github.com/cloudwego/e… 获取最新的进展。

实际效果

以下为 Reviser 智能体的最终输出:

最终答案:

总费用:门票 1100 元 + 高速票 540 元 + 餐饮 420 元 = **2060 元 **(超支 60 元,可通过午餐降级为蓝莓熊餐盒节省 60 元)

关键优化点:

  1. 高速票精准投放:仅在 “抱抱熊飞天赛车” 午间时段使用(性价比最高),其他项目利用排队低峰期

  2. 表演全覆盖:新增 “复仇者小分队培训行动”,保留所有核心表演场次

  3. 动线优化:区域集中化(未来世界→玩具的故事→幻想世界→奇幻园林→入口大街)

  4. 预算控制:通过调整午餐为快餐(蓝莓熊餐盒人均 50 元)可使总费用降至 2000 元以内

  5. 刺激项目最大化:包含 3 个过山车类项目(抱抱熊 / 矿山车 / 飞吧地平线)+ 2 个黑暗骑乘(家勒比海贼 / 派斯音速)

执行效果:在保证所有热门表演的前提下,实现 5 个高刺激项目体验,时间利用率达 92%,区域移动时间占比仅 8%

完整执行过程见链接 github.com/cloudwego/e…

相关链接

Eino 框架 Github 仓库地址:

项目官网:www.cloudwego.io

项目文档:cloudwego.io/zh/docs/ein…

让AI代码从能用变好用! Trae+火山引擎数智平台, 打造"会进化"的智能应用

在AI编程工具高速发展的今天,Cursor、Trae等工具凭借自然语言生成代码、跨语言支持等能力,极大提升了开发效率。

工具生成的代码注重功能实现。打造一款受欢迎的产品,有了能实现功能的代码并不够,还需要追踪后续数据(Track)并验证效果,才能使开发者在优化用户体验和商业决策时消除盲区。

如何让AI生成的代码真正融入业务场景,实现从“能用”到“好用”的跨越?火山引擎数智平台的DataTester(A/B测试平台)与DataFinder(增长分析工具)的深度集成,为这一难题提供了科学答案。

图片

AI 生成代码的痛点:功能完善≠效果最优

当前主流AI编程工具(如Cursor、Trae)虽能快速生成应用框架,但存在两大短板:

  1. 产品分析 数据缺失:生成的App缺乏埋点设计,无法追踪用户点击、转化路径等关键行为,导致优化无据可依。
  2. 实验验证能力不足:功能上线后难以通过A/B测试验证不同版本的效果差异,只能依赖主观判断或事后分析,试错成本高。

以电商场景为例,AI生成的促销页面可能因按钮位置、文案差异影响转化率,但若无埋点与实验能力,开发者无法量化哪种设计更优,最终导致资源浪费。


火山引擎 DataTester  +  DataFinder  :补齐 AI 工具的最后一环

在Trae中结合火山引擎数智平台(VeDI)的产品,将能获得比使用单一的AI编程工具更好的使用体验;通过数据产品的辅助,AI编程结果可以更好地进化迭代。

火山引擎数智平台(VeDI)的两大核心产品——DataTesterDataFinder,通过“数据采集+智能实验”的组合,为AI生成的代码注入全链路优化能力:

  1. 行为 数据 追踪:从“功能实现”到“数据驱动”
  • DataFinder提供轻量级SDK,支持一键集成到Trae生成的代码中,自动采集用户点击、停留时长、转化漏斗等行为数据,并生成可视化报告。
  • 例如,开发者通过Trae生成的购物车页面,可借助DataFinder分析用户从加购到支付的流失节点,定位体验瓶颈。
  1. A/B实验验证:科学决策取代经验主义
  • DataTester提供三类实验能力,适配多场景优化需求:
    • 策略迭代实验:测试不同UI设计、算法策略的效果差异,例如推荐算法模型A/B测试。
    • 功能发布实验:结合Feature Flag功能,实现代码功能的无感下发与灰度发布,降低线上风险。
    • 增长营销实验:针对AI生成的广告素材、落地页,快速验证点击率与转化率,优化投放ROI。
  • 例如,Trae生成的应用可通过DataTester对比不同的用户注册界面,结合DataFinder采集转化数据,选出转化率最优方案。
  1. 全链路闭环:从生成到优化,  AI 全程参与
  • Trae 生成代码 → DataFinder 埋点 追踪 → DataTester 实验验证 → AI 模型反馈调优,形成完整闭环。
  • 火山引擎DataTester支持与大模型联动,例如通过实验数据反哺Prompt优化,让AI生成的代码更贴合业务目标。

案例实践:  AI 工具+  火山引擎  ,释放业务增长潜能

场景1:社交App弹窗优化

  • 问题:AI生成的弹窗样式单一,用户关闭率高。
  • 方案
    • 使用DataTester创建多个弹窗设计版本(如按钮位置、文案语气)。
    • 通过DataFinder分析各版本的点击率与留存率。
    • 实验结果显示“底部按钮+趣味文案”组合的转化率提升32%,全量上线。

场景2:  电商推荐 算法迭代

  • 问题:AI生成的推荐模型效果不稳定。
  • 方案
    • DataTester并行运行新旧算法版本,划分流量对比GMV指标。
    • 结合DataFinder的用户路径分析,定位高价值群体的偏好差异。
    • 实验数据反馈至Trae的AI模型,优化后续代码生成逻辑。

未来展望:  AI 开发者的“科学工具箱”

随着火山引擎DataTester与DataFinder的深度整合,AI编程工具正从“代码生成器”进化为“业务增长引擎”。开发者可专注于创新设计,而数据埋点、实验验证等繁琐环节交由平台自动化处理。这一模式不仅适用于互联网行业,在金融、零售、汽车等领域的数字化场景中同样潜力巨大。

立即行动

  • 访问Trae官网(trae.com.cn)体验AI代码生成。
  • 扫描二维码,获取DataTester与DataFinder的定制化解决方案,让每一行代码都精准命中业务目标。

通过“AI生成+数据智能”的双轮驱动,开发者将真正实现从功能开发价值创造的跨越,开启效率与效果并重的新时代。

图片

AIBrix 深度解读:字节跳动大模型推理的云原生实践

AIBrix 项目目前已经开源,本文为 AIBrix 技术解析。详见:
🔗 vLLM 博客:blog.vllm.ai/2025/02/21/…
🔗 代码仓库:github.com/vllm-projec…
🔗 技术详解博客:aibrix.github.io/posts/2025-…

01

前言

随着 LLaMA、DeepSeek、Qwen 等开源大模型的快速崛起,企业在模型部署的灵活性、成本与自主可控性方面迎来了新的机遇。然而,仅靠对模型本身的优化尚不足以将这些模型部署成高效且可扩展的生产级 API。大模型推理往往引入诸多独特的系统挑战,如 GPU 弹性伸缩指标的非线性问题,长尾模型和精调模型流量过低的问题,多机推理时的角色编排以及 GPU 卡型的异构管理等,都对易用性和成本控制提出了更高要求。因此,我们需要从推理引擎到底层基础设施进行全栈系统设计,才能真正让大模型在生产环境中长期稳定且高效地运行。

AIBrix 作为首个基于 Kubernetes 的企业级推理系统项目,正好填补了业界在 “系统层” 上的空白。它通过优化资源调度、自适应扩缩容、缓存感知路由以及异构计算管理等多项能力,为企业级大模型的大规模部署提供高效、低成本、可扩展的解决方案。AIBrix 与 vLLM 等推理引擎深度协同,持续优化推理效率,并融合多项前沿研究成果,推动大模型推理走向更加高效、可落地的生产化阶段。

02

AIBrix 的项目背景与设计理念

在规划 AIBrix 项目的过程中,我们始终站在基础架构的角度,思考如何在大规模场景下为推理引擎提供更好支持。结合字节跳动内部的业务实践,我们发现,大模型往往会带来一系列与传统微服务截然不同的系统挑战,包括:

  • 模型权重的下载 / 加载:如何快速分发和加载体积庞大的模型文件,降低冷启动延迟。

  • GPU 弹性伸缩指标的非线性:大模型对 GPU 的利用率并非线性关系,传统的指标收集与弹性策略常常滞后或不精准。

  • 长尾模型或精调模型流量低:针对这些流量低但又需要及时响应的模型,如何做到有效的资源利用和成本控制。

  • 多机推理的角色编排:在分布式推理场景下,如何更高效地在多个节点之间分配和调度任务。

  • GPU 卡型异构:不同型号、不同性能的 GPU 共同部署时,如何协同工作并优化利用率。

  • 单服务跨 Region:多数据中心与跨区域部署的需求增大了同步管理模型与推理任务的难度,同时对容灾与可用性提出了更高要求。

传统微服务框架(如 KNative)或服务网格(如 Istio)在鉴权、流量管控、版本升级等通用能力上已经相当成熟,但对于大模型服务而言仍然显得过于臃肿,且缺少针对性的优化。此外,市面上大多数项目往往将推理引擎视作一个 “黑盒”,无法进行深度协同优化。

设计理念

为应对上述挑战,AIBrix 的核心理念在于通过 “引擎层” 与“系统层”的紧密协同,搭建一个轻量化、云原生的方案。具体而言,我们将部分通用的引擎功能卸载到系统层面进行管理,并对模型推理常用能力进行封装,向外提供统一的引擎接口层。这种模式能够在大规模场景下同时兼顾性能、成本和易用性,帮助企业级大模型部署实现更高的弹性和可控性。

系统架构

AIBrix 包含控制平面组件与数据平面组件,并完全基于 Kubernetes 进行开发,采用完整的云原生设计来确保系统的可扩展性、可靠性以及资源效率。AIBrix 充分利用了 Kubernetes 的现有功能,包括自定义资源 (CRD)、控制器机制以及动态服务发现等,为大规模 LLM 推理服务提供了稳健的基础设施。

控制平面组件主要负责管理模型元数据注册、自动扩缩容、模型适配器注册,并执行各种策略。数据平面组件则提供可配置的请求派发、调度与推理服务能力,实现灵活且高性能的模型推理执行。下图为 AIBrix 的系统架构

AIBrix 项目已发布了 v0.1.0 和 v0.2.0 两个版本。在 v0.1.0 阶段,我们主要针对 Serverless 场景进行了一系列优化,着重解决冷启动、弹性伸缩和高密度部署的问题。而在 v0.2.0 阶段,我们则聚焦于分布式与解耦化,通过多机推理、KV-Cache 管理以及异构计算管理等特性,让大模型的规模化部署更加高效可控。

03

AIBrix v0.1.0:Serverless 与高密度部署

Serverless 与弹性伸缩

AIBrix v0.1.0 的主要思路是将大模型在生产环境中面临的核心难题,与 Serverless 领域的几项关键技术(冷启动、快速伸缩与高密度部署)相结合。我们并不追求让大模型像 FaaS 一样彻底 “无服务器化”,因为这在现实中尚难达到理想效果,也并非企业级生产环境的最佳形态;更可行的路线是借鉴并改进 Serverless 的相关思路,对大模型的部署环节进行有针对性的优化。

线上观察:Autoscaling 与指标挑战

在实际应用中,Autoscaling 最大的难点是:流量波峰和推理实例利用率之间通常存在显著的时间滞后(常见在 2~5 分钟),导致高并发场景下容易出现短时过载,从而拉升长尾延迟。此外,传统的 GPU 性能指标(如 DCGM 暴露的 DCGM_FI_DEV_GPU_UTIL 或 DCGM_FI_PROF_SM_ACTIVE)严重依赖引擎自身实现,也很难体现 GPU 空间利用率,导致扩缩容决策往往不够精确。

多种伸缩方案探索

为此,我们尝试过将引擎 KV_CACHE 利用率 与队列中待处理请求的输入 / 输出指标结合起来,做出更精细的扩缩容判断。然而在实际业务中,保障 SLO(而非 GPU 利用率)通常是更高优先级的目标,这使得传统基于资源利用率的 Autoscaling 策略效果有限。为了应对这一挑战,我们又探索了 基于 Profiling 并以 SLO 驱动的扩缩容方案,通过对历史与实时流量分布进行分析,动态确定扩缩容时机,减少过载并降低尾部延迟。

目前,AIBrix 在此方向上仍在持续迭代研究,包括尝试更具前瞻性的 LLM 专用指标,以及 Proactive 主动式弹性策略,让系统在应对突发流量时更加游刃有余。

在架构设计中,v0.1.0 主要引入了 Gateway API Plugin (Envoy) + Runtime 这两个组件,以适配大模型通常面对的两类路由方式:应用层路由(app router) 和 代理层路由(proxy router)。在大模型社区,如 vLLM 正不断丰富自身 API(含 token、transcription、score 等),保持与引擎原生接口一致是一项不小的挑战。为此,我们采用了高性能标准化的 envoy gateway 配合 extension server 来实现定制化,来进行高性能且可定制化的流量管理:

  • 只在必要处做 request head/body 的修改,尽量避免重复实现类似 OpenAI 的 API;

  • 同时支持对请求进行缓存感知的调度,包括 kv cache least used、least of prompt、prefix-cache aware 等策略,以进一步缩短长尾 TTFT(Time to First Token) 等性能指标。

冷启动与模型加载优化

在冷启动问题上,我们重点考察了不同机型在 网络带宽、本地盘 I/O、RDMA 等方面的性能差异。虽然云原生社区已有如 Fluid 等项目可在 “1 -> N” 场景下发挥缓存加速作用,但在 “0 -> 1” 阶段,磁盘 I/O 并不总能比网络更快,有时通过 远程流式加载 直接将权重加载进 GPU memory 反而效率更高。

为此,AIBrix 在 v0.1.0 中实现了 GPU 流式加载 方案,支持在 Tensor 层面更细粒度地控制下载速度和顺序,为开发者提供灵活的组合策略。需要注意的是,若机型配有本地 NVMe 磁盘,则本地加载可能仍优于远程;而在分布式文件系统场景下,单机自我读取也能减轻对共享文件系统的集中访问压力。AIBrix 将这些能力进一步封装,开发者可基于自有机型和带宽状况,自行选择最佳加载方式。

高密度模型部署

对于精调模型(如 LoRA),实现高密度部署是释放其竞争力的关键。我们在 vLLM 项目中做了大量改动来支持 LoRA 的动态部署与度量,血缘关系追踪、LoRA metrics 单独计量等关键特征,方便与 AIBrix 控制面深度集成。但这其中依然存在若干未解决的挑战,我们正在逐步完善并计划在后续版本中支持更多功能:

  • 单容器混合部署:目前基本模型(Base Model)和精调模型(LoRA)常被打包在同一容器,虽然能减少部署节点,但也打破了容器隔离以及不可变性的原则,某些场景会因过载触发部署失败。

  • Adaptive LoRA batch、dynamic merge 等高级功能还在持续研发当中,旨在进一步提高同一 GPU 上运行多个模型或微调版本的效率。

  • 定制化内存分配器(memory allocator):在固定 GPU 资源中快速换入换出不同基础模型,利用引擎原生的 CUDA 虚拟内存(visual memory)管理能力,使多模型部署具备更好的鲁棒性与伸缩性。

04

AIBrix v0.2.0:分布式与解耦系统

分布式编排和多机推理

AIBrix v0.2.0 的核心工作在于构建分布式与解耦(Distributed and Disaggregated)系统,其中分布式部分主要关注多机推理的编排。我们在对 DeepSeek-R1 671B 模型、16 卡满配场景下进行验证后,已经实现了较为稳定的分布式推理方案。具体来说,AIBrix 采用 Ray 来编排分布式推理任务,原因包括:

  • vLLM 自带分布式 runtime:默认支持 Ray 与多进程架构,为分布式推理奠定良好基础。

  • KubeRay 场景经验积累:AIBrix 项目的核心成员曾主导 KubeRay 的开源工作,对如何在 Kubernetes 与 Ray 之间实现高效整合有着丰富的实践。目前,KubeRay 是行业通用的 Ray on Kubernetes 编排方案,被广泛应用于包括字节跳动在内的多家企业生产环境。

  • 云原生的多角色编排:在一个 CRD 中灵活编排不同容器或角色(如 TP/PP 等)并非易事,而多机调度策略也可能因具体业务场景(例如 P&D、Splitwise 论文提出的 Router/CLS、Mixed Pool 或 vLLM xPyD 等)而改变。通过 “混合编排(Hybrid Orchestration)” 理念,让 Ray 负责应用内部的角色管理,Kubernetes 则专注于升级、伸缩等通用工作,双方分工明确且更具灵活性。

   在实际实现中,我们将一个多容器推理实例视作一个 Ray 应用,用 RayCluster 来进行描述,再由   RayClusterFleet 负责升级与扩缩容等通用任务。除此之外,我们还在 vLLM 中加入了额外的弹性功能,允许集群节点在资源不足时先行等待,触发 Pod 调度与自动扩缩容后,再承接推理负载;这一改进在生产环境中显著提升了容错与鲁棒性。

KV Cache 组件管理

在 Prefix/Session Cache、P&D Disaggregation、跨机请求迁移等场景中,KV Cache 组件扮演至关重要的角色。如果仅放在推理引擎内部,诸如跨机分享 KV Cache 等操作就会非常复杂。为此,AIBrix 通过分布式 KV 缓存来应对这些挑战,不仅实现了跨引擎的 KV 复用,同时也在网络与内存效率方面进行了优化。我们的方案采用了一种可防扫描(scan-resistant)的淘汰策略,有选择地保留热点 KV 张量,从而最大程度地减少不必要的数据传输;此外,通过异步方式维护元数据更新进而降低系统开销,并在缓存与引擎的协同部署(colocation)中利用共享内存进行更快速的数据传输。

在实际部署场景中,我们发现:

  • 内存层次优化:在 prefix cache 等场景中,  如果低端 GPU 型号模型加载已经占用大部分 HBM 显存,留给 KV Cache 的空间十分有限;此时可借助空闲的 CPU DRAM 做 “二级” 缓存,能实现一定程度上的容量扩展。需要注意的是,从绝对性能角度,这种方案不可避免地会带来从 CPU DRAM 到 GPU HBM 间数据交换的额外开销,但在容量与性能间取得平衡对于某些业务仍然十分必要。

  • 灵活的淘汰策略:AIBrix 还在基于 vLLM v1 的全新架构做进一步调整,向上游社区贡献更多 KV Cache 淘汰策略的实现,敬请期待后续更新。

异构计算与成本优化

在异构资源环境中,并非所有用户都能在同一集群内获取一致的 GPU 规格,常常需要混合不同型号的 GPU 来支持同一业务。而异构卡的性能差异也会影响控制面的调度与数据面的路由。

AIBrix 针对这种需求,通过 **Profiling + ILP (整数线性规划)**的组合,找到了成本最优的机型分配和部署方案。对于异构路由策略层面的能力,目前相关功能和特性也正在开发中。

故障诊断与模拟工具

故障诊断与模拟工具链接:aibrix.github.io/posts/2025-…

AI Accelerator 故障诊断与模拟工具是 AIBrix 的系统组件,基于火山引擎容器服务 (VKE) 的经验开发,针对的是 GPU 故障和性能下降在大规模 AI 部署中构成重大挑战 -- 静默错误、过热、内存泄漏和间歇性等故障可导致模型性能下降、延迟增加,甚至系统崩溃;而在异构 AI accelerator 环境中,不同 GPU 型号在不同工作负载下表现不一致,故障诊断和自动化运维更加棘手。

  • 故障检测:目前针对不同厂商的卡型能够完成自动化故障检测, 帮助用户在影响负载之前识别性能问题。

  • 故障模拟:该工具可以模拟 GPU 的性能下降或硬件故障,方便开发者测试和构建高容错能力的 AI 系统。一旦故障发生,系统能平滑恢复,降低对整体服务的影响。

  • 硬件支持:目前已支持 NVIDIA GPU 等主流 AI 芯片,后续也将持续扩展兼容更多类型的加速器。

04

AIBrix On VKE

火山引擎容器服务已实现了 AIBrix 的组件化接入,在一系列 GenAI 场景下的基准测试中,弹性伸缩性能与 token 吞吐量提升超 10%,LoRA 应用成本最高降低 4.7 倍,模型加载提速可超 50%。收益详情如下:

在上述核心特性中,弹性伸缩是连接云上应用与云服务的桥梁。接下来,我们将着重聚焦 LLM 弹性伸缩,深入探究其在 GenAI 场景中发挥的作用以及与 VKE 结合所带来的价值。

Autocsaling On VKE

资源准备与镜像预置

VKE 通过节点池统一管理实例资源,使用节点池创建 8 台 A10 单卡实例,作为实验环境。

节点池支持包年包月、按量付费、弹性预约、Spot 等多种实例交付方式,满足不同场景下的成本与可用性需求

容器镜像方面,通过预加载的方式在实例上提前拉取 deepseek-coder-7b 模型镜像,加快 Pod 拉起速度。

端到端可观测性

VKE 集成了对网络请求流入流出、各类资源状态与利用率、Kubernetes 资源对象以及应用自身运行指标的端到端观测,并且支持应用的自定义指标透出,借助这些能力,可以全面观测 LLM 应用的运行状态。对于弹性伸缩场景,观测指标一方面用于工作负载伸缩,一方面用于观察 AIBrix 的弹性伸缩效果。

实验与结论

AIBrix 集成了多种 Pod 伸缩方法,在本例中,使用 Kubernetes 原生的水平 Pod 自动扩缩器(HPA)与 AIBrix 实现的 Kubernetes Pod 自动扩缩器(KPA,可参考 KPA)进行对比。

LLM 应用负载,使用 vllm 运行 deepseek-coder-7b,弹性伸缩指标使用 vllm:gpu_cache_usage_perc,访问请求从 ShareGPT 中随机抽取,并以指定的并发数将这些请求分发给该服务。对于 HPA,AIBrix 会创建一个 Kubernetes 原生的 HPA 实例,以扩展指标的方式进行伸缩。对于 KPA,AIBrix 实现了其完整的流程,包括指标收集、对目标部署状态的定期监控以及伸缩操作。

实验数据如下所示。AIBrix 支持直接从 Pod 中拉取关键指标,因此伸缩响应速度获得显著提升,大模型应用首次伸缩响应耗时 12 秒, 相比 HPA 的 67 秒耗时加速 82%。AIBrix 的完整扩容周期为 120 秒,而 HPA 为 320 秒,加速 62.5%,并且震动频次降低 33%。

05

写在最后

AIBrix 的目标是将大模型推理的 “系统侧” 能力与 “引擎侧” 创新完美结合,提供从资源调度、网络流量控制到分布式推理的端到端解决方案。通过与 vLLM 开源社区的深度协作,我们希望不断迭代并完善在云原生环境下的大模型部署架构,让企业能够更加轻量、弹性地构建面向生产的 LLM 推理服务。

在 AIBrix 开发过程中,我们的很多创新想法都受到了学术研究的启发,比如 Preble、Melange、QLM 和 MoonCake 等,在这里我们真诚地感谢这些成果背后的研究人员。我们也非常感谢 vLLM 社区的支持,使 AIBrix 成为了 vLLM 的控制面,进一步增强了我们构建可扩展和高效 AI 基础设施的使命感。

AIBrix 由字节跳动开源,现在正在开源社区的支持下成为一个完全开源的项目——目前项目已经吸引了来自密歇根大学、伊利诺伊大学厄巴纳 - 香槟分校、华盛顿大学、Google、DaoCloud 等学术界和工业界的开源伙伴。未来,我们也希望 AIBrix 能通过开放、协作的方法塑造 AI-Infra 的未来,持续将顶尖学术研究和行业内的生产级实践结合起来。也欢迎更多开发者和企业加入我们,为开放、可扩展的 AI 基础设施的未来做出贡献:github.com/vllm-projec…

285 学科全覆盖!豆包大模型团队开源基准测试集 SuperGPQA

近日,豆包大模型团队开源 SuperGPQA,一个领域全面且具备高区分度的知识推理基准测试。

该数据集构建了覆盖 285 个研究生级学科、包含 26529 道专业问题的评估体系,不仅涵盖主流学科,更将轻工业、农业、服务科学等长尾学科纳入其中,展现出全面学科的覆盖广度,填补了长尾知识评估领域的空白。

如今,SuperGPQA 已被用于揭示开源模型与闭源方案之间的显著性能差距,为 AI 发展提供了关键评估工具和跨学科分析框架。

随着大语言模型在通用学科中的表现逐渐接近人类水平,研究焦点也随之转向其在真实世界专业领域的应用。然而涉及人类研究领域的长尾学科时,由于有效评估的缺乏,LLM 的能力边界依然模糊不清。

为了全面衡量 LLM 的泛化能力与推理上限,字节跳动豆包大模型团队联合 M-A-P 开源社区推出基准测试 SuperGPQA,这一基准不仅覆盖了二百余个研究生级学科,还确保 42.33% 的题目需要数学计算或形式推理,构建了兼具广泛学科覆盖与复杂问题设计的评估新范式。

实验结果显示,DeepSeek-R1 在 SuperGPQA 上的准确率为 61.82%,在不同知识领域中,当前大语言模型性能仍有很大提升空间,这也进一步凸显 SuperGPQA 在评估模型真实能力方面的重要性和必要性。

⽬前论⽂成果和数据代码仓库均已对外公开,欢迎开源使用!

SuperGPQA: Scaling LLM Evaluation across 285 Graduate Disciplines

论文链接: arxiv.org/pdf/2502.14…

数据链接: huggingface.co/datasets/m-…

代码链接: github.com/SuperGPQA/S…

1. 现有评测基准学科占比失衡,长尾学科覆盖不足 5%

现有大语言模型评估体系主要面临两大核心困境:学科覆盖的严重失衡与评测基准的挑战性失效。

以 MMLU 和 GPQA 为代表的传统基准尽管在数学、物理等主流学科中建立了标准化测试框架,但其覆盖的学科数量通常不足 50 个,仅占人类知识体系的冰山一角。据统计,现有基准对轻工业、农业、服务科学等长尾学科的覆盖率甚至不足 5%。

多基准多维度对比雷达图

不同基准下最新模型的性能对比

更为严峻的是,现有评测体系失去区分度,无法有效衡量模型在真实复杂场景中的推理上限。比如,主流模型如 GPT-4o、DeepSeek-R1 在传统基准上准确率已突破 90%。

这主要源于传统基准构建范式的单一化数据来源与粗放化质量筛选。比如,不加辨别地依赖教科书例题或在线题库(例如 GPQA 中 42% 的问题来自维基百科),导致题目缺乏专业深度,且易被模型通过记忆机制 “破解”。实验发现,GPT-4o 对在线练习网站答案的重复率高达 67.3%,暗示其性能提升可能源于题目数据泄露而非真实推理能力。

此外,众包标注的专业水平参差和主观性问题评估难度进一步加剧了基准的不可靠性——早期尝试中,仅 37% 的众包标注问题通过专家审核,导致超过 60% 的标注资源浪费。

这使得我们无法准确评估模型的泛化能力和推理能力,严重阻碍了模型性能的进一步提升。

2. 首次全覆盖 285 个学科,探索 LLMs 真实能力边界

为突破以上限制,豆包大模型团队和 M-A-P 历时半年推出 SuperGPQA,一项全面的基准测试,实现 285 个研究生级学科全覆盖,旨在探索最先进的大语言模型潜力边界。

  • 全面学科覆盖 SuperGPQA 覆盖 13 个门类、72 个一级学科和 285 个二级学科,共 26,529 个问题,远超现有的 GPQA(448 题)和 MMLU-Pro(12,032 题),平均每题将会提供 9.67 个选项,挑战性显著高于传统的 4 选项格式。同时,它突破传统评测集仅侧重 STEM 学科的局限,兼顾科学、工程、医学等 STEM 学科与哲学、文学、历史等非 STEM 学科问题,且具有较高区分度。

  • 多样的难度分布: 问题难度在各学科间均衡分布,尤其在工程和科学领域,难题比例较高。42.33% 的问题需要数学计算或严谨推理,确保模型在高难度任务中的表现。

  • 丰富的语义结构: 通过 t-SNE 可视化,评测集 SuperGPQA 展示了跨学科的聚类模式,工程和科学类问题在语义上高度相似,人文学科则保持独特的知识中心,体现了领域特定的语言特色。

  • 一致的题目设计: 平均问题长度为 58.42 字,选项长度一致,增强了迷惑性和挑战性,确保评测的公平性与可靠性。

3. 专家 - LLM 协同,提高题库质量

SuperGPQA 的核心架构由三个关键阶段组成:源筛选、转录和质量检验。该过程涉及 80 多名专家标注员、交互式专家 - LLM 协作系统,为未来类似规模的研究项目提供了方法指导。

SuperGPQA 数据收集处理流程

  • 源筛选

为确保题目的高标准质量,团队摒弃了众包注释员收集资源的方式,转而由专家注释员负责从可信来源(如教科书和权威练习网站)筛选和收集原始问题,并要求提供来源截图。这一策略避免了早期大量无效问题的产生,提升了质量检查的效率和准确性。

  • 转录

在转录阶段,专家注释员对收集的原始问题进行语言规范化和格式转换,确保所有问题具备统一的学术语言和标准的多项选择题格式。团队发现,即使是最先进的语言模型(LLMs)在生成干扰项时也存在漏洞,因此需要专家统一重写,以提高干扰项的准确性和有效性,确保题目的挑战性和区分度。

  • 质量检验

团队在质量检验阶段采用三层检查机制,以保证数据集的整体质量:

1)基于规则的初步过滤: 识别并过滤格式明显不合规范的题目。

2)基于 LLM 的质量检查: 利用多个先进的 LLMs,如 GPT-4、Gemini-flash 等,进行有效性、负面和极端询问检测、多模态排除、领域相关性评估和区分度标记。通过多模型协作,不仅提升效率,还降低数据泄漏风险。

3)专家复审: 由专家注释员对可疑题目进行二次审核,确保最终题库的高可靠性和高区分度。

4. 最优推理模型仍有进步空间

发布评测基准的同时,研究团队也基于 SuperGPQA 对全球 6 个推理模型、28 个聊天模型和 17 个基础模型进行了评测,涵盖闭源、开源和完全开源三类模型。

其中,推理模型和聊天模型采用零样本评估,基础模型采用五样本评估(方法与 MMLU-Pro 类似),并将温度参数设置为 0,推理模型最大生成 token 数为 32K,其他模型为 4K。

我们的实验结果表明,在不同的知识领域中,当前最先进的大语言模型性能仍有很大提升空间,如当前最优模型 DeepSeek-R1 在 SuperGPQA 上的准确率仅为 61.82%。具体评测结果如下图所示:

LLMs 在不同划分层级的表现

LLMs 在不同学科的表现

  • 指令微调显著提升性****能

DeepSeek-V3 和 Qwen2.5-72B-Instruct 的得分(47.40 和 40.75),远超其基础版本得分(32.14 和 34.33),验证了指令微调的有效性。

  • 大模型表现更均衡

DeepSeek-R1 在简单(63.59)、中等(63.63)和困难(56.87)题目上均表现优异。相比之下,Qwen2.5-14B-Instruct 在同类别题目上的表现差距较大(44.82、37.90、19.97)。

  • 推理模型训练范式仍有待优化

DeepSeek-R1 与 DeepSeek-R1-Zero 性能差距不大,尤其在科学与工程领域,后者稍占优势,表明最佳训练方法尚未确定。

  • 预训练语料库的持续优化

LLM 系列如 Qwen-max、GPT-4o 模型系列在 SuperGPQA 上的表现随着时间显著提升,显示开发者高度重视长期知识的融入。

  • 开源模型面临挑战

尽管透明 LLM 如 MAP-Neo-7B 和 OLMo-2-1124-13B 表现尚可,但与业界的非透明开源和闭源模型相比,尤其在困难题上仍显不足。

  • 不同能力的模型表现差异

其中,Doubao-1.5-pro 以 55.09% 的准确率在 Chat Models 中位列第一,我们发现,通用大语言模型(如 Doubao 系列)在常见专业问题的知识回忆方面表现不错,但在长尾领域的推理方面存在困难。

o3-mini 系列在简单和中等难度题目的分数低于 Doubao-1.5-pro ,但在困难问题上却明显超过它,说明推理模型在难题上表现突出,却在广度知识覆盖方面存在不足。

5. 历时半年,探索模型真实能力边界

SuperGPQA 评测集搭建历时半年,近百位学界学者及硕博同学、业界工程师参与标注。通过 LLM - 专家协作的构建流程、285 学科全面覆盖和多样难度分布设计,SuperGPQA 填补了长尾领域专业评估的空白,有望成为衡量 LLM 泛化能力与推理上限的关键工具。

其实验结果不仅揭示了当前模型能力与通用人工智能之间仍存在巨大差距,也为 AGI 发展提供了跨学科分析框架。未来我们也将进一步扩展数据集范围、改进人类与模型协作标注模式,以应对快速演进的人工智能技术挑战。

AI 与星辰大海:2025,从新手到开挂勇士的奇幻旅程

作者:Data-TnS-Engineering-FE 团队

前言

曾几何时,代码敲击声回荡在深夜的办公室,你是否也曾幻想过有一个全能助手替你分担工作?如今,这个美好的愿景不再是空中楼阁。

想象一下,当你正为产品设计苦思冥想时,突然耳边传来 AI 的灵感火花;

开发过程中,AI 像是个比你还了解自己的最佳拍档,为你提供独到的建议;

当繁琐的测试工作如排山倒海而来,它早已帮你先行解决那些隐秘的 Bug;

交付环节则如同一个老练的质检专家,在每一个细节上都帮你擦亮眼睛;

而在运维阶段,它更是你的“夜间守卫者”,早早预警潜在问题;

有人说,AI 的到来让开发者的身份发生了质的飞跃,从“头发稀疏的代码独行侠”变成了“开挂勇士”。在 AI 的协助下,谁不想在产品设计上满怀创意、在代码编写上行云流水、在测试中无懈可击,在运维中高枕无忧呢?

在这个充满奇思妙想的科技时代,AI 既是你的忠实伙伴,又是你的全能助手,更是一位风趣的导师。它不知疲倦地助你不断攀登职业高峰,让每一步开发都像是一场精彩绝伦的探险。是时候跳出你的舒适区,体验 AI 如何点亮开发者们的星辰大海。

准备好了吗?接下来,我们将揭开这段 AI 助力开发的奇妙旅程,从产品设计到运维管理,为你重新定义“效率”、“质量”和“体验”。 每一刻都充满了独特的惊喜和乐趣,相信这将是一段你不想错过的神奇之旅。让我们一起踏上这段充满无限可能的旅程,重新发现开发的独特魅力与无穷乐趣。

在 AI 的魔法世界里,开发者不再是孤军奋战的英雄”

助力业务发展

随着 LLM 能力的迭代和更新,越来越多过去 LLM 无法很好解决的问题重新进入了大家的视野。在人审领域下人们一般都会围绕着 LLM 是否可以完全替代审核员对内容进行审核进行讨论与探索。在进一步进行详细的 LLM 赋能人工审核流程相关的例子前我们需要先了解一下“人类审核员”面对的一些挑战和要求:

  • 能处理复杂的内容(e.g.可能既可以是 A,也可以是 B,还可以是 C)并给出最合适的答案
  • 质量 & 稳定性:对于相同类型的内容要给出相同的结果
  • 效率:在不损伤质量的前提下需要达成一些数量上的要求

先说结论目前 LLM 的能力还无法完全替代审核员,更多的是在各方面提供辅助从而提升审核员的审核质量和效率。所以在产品结合的思路上,我们主要关注在如何利用 LLM 简化或加速审核员对于单一任务的操作并完成了一些功能的落地。

前置思考

说到 LLM 最出名的当属于 ChatGPT 了,如 GPT-4o

当我们思考 LLM 能为审核员带来什么样的辅助时我们首先想到的就是如何利用这些现有的天花板模型。这些模型一般都有良好的指令执行能力,并且在处理一些通用文字相关的问题时一般具有非常好的表现。

例如我们可以提供一段文字内容给模型,并告诉模型我们希望它帮助我们从中提取出不符合某些规则的内容如:潜在的语言攻击,歧视内容,潜在色情内容等等。

当有了模型识别出来的内容后可以通过一些特殊的形式展示这些信息如高亮等。 这可以帮助审核员在审核过程中快速捕捉到内容中存在的潜在风险并加速其对当前内容的审核。听起来是不是很简单?实际上将一个如此简单的辅助能力从离线验证到最终上线需要考虑的远远不止这些:

  • 成本:模型是以 token 来进行计算的。一段文字会先被转换成 token 然后再传给模型,同时模型输出的也是一堆 token 然后会再被转换成我们看得懂的语言。所以如何无损高效的压缩传给模型的内容是一个非常重要的课题。(如删除重复的内容等)
  • 合规:因为模型在迭代过程中需要大量的训练数据,厂商都会收集模型在实际使用过程中的数据以补充其训练数据集。如果将一些敏感或隐私数据不经过处理的直接传给模型可能会带来合规风险。(如公司的保密数据,或者用户的不公开信息等)我们可不希望当其他人问模型你的银行卡密码时模型能准确无误的回答上来hhh。
  • 时延:模型能力强大是有取舍的。可以姑且先理解为(在计算资源不变的前提下)模型的规模越大->模型的能力越强 -> 每次回答你问题的速度就越慢。同时给模型输入越多时返回的时长也会相应的延长。
  • 其他 n+ 问题:服务可用性,模型拒绝回答,模型选型等等...

但是由于模型能力的局限性,我们只能对标准文字内容审核提供符合标准的辅助能力。那对于像视频或者音频中出现的文字,或者话语或者一些复杂的问题怎么办?

例如我们想对于一个歌曲中的歌词进行风险识别。

这个时候我们就要引入分步的解决思路,歌曲中歌词的风险识别可以被拆分成如下工作流

暂时无法在飞书文档外展示此内容

举例来说,在预处理环节我们可能会需要对 ASR 转换的文字内容进行整理如加入标点符号,分句断句等。模型对于长文字内容的处理能力会随着内容变长而下降。我们需要根据业务的诉求和实际情况进行灵活调整。抛去 ASR 环节不说,后边三步可以有两种实现思路:

  1. 在一个 Prompt 中通过分步的方式指导模型进行处理

    1. 会更好的保留整体的上下文,但是可能会碰到如内容太长超出 context window size 的情况并且随着内容的变长模型的完成时间也会变慢。
  1. 将每一步拆分开然后通过串行调用的方式完成多次模型调用。

    1. 可能会丢失一些上下文内容,但是因为进行拆分后每一部分的长度都是相近的所以在模型响应时长和 context window 上限的问题上则有比较好的表现。

如果我们想让模型帮我们翻译火星文呢(没办法,业务上就是有这个诉求 hhh)

对于一些有高定制化诉求的场景普遍厂商也会开放对现有模型就行二次训练的能力。比如对于将内容从 A 语言翻译到B 语言并且对语言风格/用词有明确需求时,可以考虑对基本模型做一次 SFT(Supervised Fine Tuning) 。也就是对自身特殊诉求收集数据集并使用这个数据集对模型进行二次训练以达到更好的表现。

如果你说识别什么的还是太复杂了,有没有更简单的应用场景?

在实际产品开发过程中,我们经常性的需要对圈定/给定的一组数据进行频繁的离线验证以确保目前的能力表现是符合我们预期的。又或者在产品迭代的过程中我们时常需要一个指标/分数来量化当前的能力/体验。举例来说,在进行多目标语言混合翻译的能力开发过程中,我们在 prompt enginnering 过程中需要时刻关注模型的表现并确保每一次修改都不会对能力造成较大的退步。Multidimensional Quality Metrics (MQM) 是一个多维度指标翻译质量分析框架。我们可以通过自然语言的方式向模型输入我们的对于(译文与原文对比)不同维度上的要求如:

  • 翻译准确性

    • 是否丢失一些信息
    • 是否凭空捏造了一些信息
    • 翻译错误/未准确翻译
    • 未翻译
    • ...
  • 流畅度

    • 语法是否正确
    • 标点是否正确
    • 拼写
    • ...
  • 风格

    • 是否用了一些抽象的词

    • 是否是正式文风

    • ...

得益于 LLM 优秀的自然语言处理能力和跨语言能力,LLM 可以基于我们输入的多个维度来对译文进行评估并最终给出评估结果。需要注意的是为了保证模型输出结果的准确性我们一般不会直接要求模型输出如:0-10 分的打分。我们会尽量让模型在一个给定的状态下进行枚举。如:严重程度(严重, 普通, 可忽略不计)并通过代码对枚举进行映射后计算出最终的得分。这样可以最大程度上避免 LLM 输出不稳定和幻觉等问题。

实际上 MQM 这种多维度评估体系也可以应用在翻译之外的领域,如润色、故事生成等。甚至可以被用在图片打分。他们背后的原理都是类似的,都是通过发挥 LLM 出色的自然语言理解能力 + 通过自然语言描述框架来实现一些复杂的打分工作。

功能落地

上边我们只是利用了 LLM 的自然语言处理能力。如果我们想让模型帮我们回答一些不是通识性知识点的问题时该怎么办呢?

暂时无法在飞书文档外展示此内容

模型也可以像人一样,碰到不会的东西时可以先去查询然后再基于查询结果进行判断和回答

比如说我们想为审核员提供一个问答机器人回答一些审核领域相关的问题,或者基于以往的审核结果进行回答。

又或者我们想基于某一个数据库中的结果对审核员的问题进行回答

但是对于不同类型的内容,知识库在生产过程中采用的分割策略,结构等会大大影响最终问答质量的表现。如:

  • Chunking: 如当对于大段内容进行拆分时要拆分的多细,拆分后的信息是否应该保留整体上下文方便后续参考时使用等。
  • Embedding Model: 需要针对需要支持的语言来选择向量化模型,不同的模型在召回准确度上也有不小的区别。

当我们检索到的相关知识后可以通过将这些信息连同原始问题一并作为输入给模型并让模型基于相关知识点尝试回答。

当我们积累了一系列能力后,如果快速的向其他方向上推广和扩展?

随着越来越多基于 LLM 能力的需求落地,我们发现其实所有的 LLM 能力都可以被总结为:一个带顺序的分步流程。其中模型调用只是工作流中的一个步骤/节点。如果我们能快速的复用这个分步流程就可以快速的对取得成功的能力进行推广。

(分步流程可视化示意图)

随着我们对现有的一些优秀 LLM 编排能力库/平台的深入了解,我们发现现有的方案都无法很好的对我们的业务场景提供100%的支持。甚至大多数都无法通过合规这一环节。更不用说可能业务有自己的模型、数据库、基础能力等等。我们需要在业务下实现一套定制的流程引擎

流程引擎本质上可以被拆解为一下两种图的类型:

  • DAG 有向无环图。一般用作承载经典 Workflow 场景。需要注意的是这种图不能包含循环所以一般被用作实现单一 Agent 能力。
  • FSM 有限状态机。用作实现如 Multi-Agent 场景或有环的场景。是 DAG 的一个补充。但是需要注意的是因为状态机同时之后有一个激活状态所以并发分支等能力无法通过 FSM 实现。

当我们实现了上述两种流程引擎后可以进行组合实现更复杂的能力如:FSM 中嵌套 DAG,DAG 中嵌套 FSM 等等。

当我们有了上述流程引擎和对应的 DSL 之后。我们就可以在业务间快速复用能力(只要复制一下 DSL 或基于现有的 DSL 做二次开发就好了)。

可能你会问,为什么不根据需求直接把这些逻辑写在代码里呢?实际上在日常开发过程中我们发现大部分功能都是通过对有限能力的组合来实现的。如果不做流程引擎的建设会带来很大效率上的降低以及多余的开发量。

其他实践

Hornbill 是内部用于多个平台的 Oncall 工单管理工具,拥有三种升级策略。我们处理工单的团队包括用户运营团队和研发工程师,并在创建工单时自动拉入相关人员进行协作。由于审核员需要快速处理大量审核任务,Hornbill 通过 24 小时的用户运营团队快速解决问题,技术问题会被升级至研发解决。

Hornbill SDK 可整合到平台中,帮助用户在提交工单前通过 FAQ 寻找解决方案,从而减少不必要的工单。每周约有 几百个由用户运营团队处理的工单,因此减少工单数量可以让团队将时间用于更高价值的任务。

为了提高工单解决的效率并减少工单数量,我们引入了 AI 功能。

首先,相似工单检测能够有效识别并链接具有相同问题的工单,通过创建问题描述的嵌入向量,并使用向量相似性计算如余弦相似度,系统在用户提交新工单时可提示已有相似工单,推荐用户加入现有工单而非新建,或在工单提交后提醒用户操作团队进行链接,从而减少重复工单处理的工作量。

其次,AI 摘要生成功能则通过自动生成问题的总结,在群聊中梳理出问题描述、原因和结论,提供给不同班次的用户操作团队以保持问题处理的一致性。这一功能通过将所有的群聊内容传递给大型语言模型生成总结,从而避免因班次交接导致的上下文丢失,提高工单解决的连续性和效率。这些先进的 AI 功能减少了用户操作团队在票务处理上的重复性工作,让他们能够将更多时间投入到更具价值的任务中,不仅提升了团队的工作效率,也提高了用户问题解决的速度和质量,为用户提供了更优质的体验。

另外,Hornbill SDK FAQ 搜索功能旨在解决用户自行反馈且可通过非技术知识解决问题的工单。 通过将常见问题生成 FAQ,用户可以搜索相关知识库,并根据输入查询定制 FAQ 内容,使其更易理解。方法是为常见的可自解决问题创建 FAQ,并对 FAQ 问题进行向量嵌入,将其存储在向量数据库中。当用户输入问题时,我们利用余弦相似度比对向量嵌入,以找到匹配的 FAQ,并通过大型语言模型总结 FAQ 内容,展示给用户。这一功能减少了不必要的工单,提升了用户的自助解决效率。

总结

当我们完成了对流程引擎的落地,同时在流程中有技巧性的使用 LLM。基本上领域下的产品/业务诉求都可以基于这一套框架来实现。LLM 除了可以为我们做分类,识别等功能以外也可以帮助我们做离线验证/数据评估等工作。 与可视化编排界面配合可以大幅降低使用门槛。对于一个想要使用 LLM 能力来实现业务诉求的业务方,不论是 PM 同学还是运营同学都可以进行尝试可以大大解决研发的人力缺口。

提升开发体验

AI 编程在今年有了比较大的发展,因为出现了 Cursor、Windsurf、v0、bolt.new 这些,在不同场景下,成为了能指数级提升生产力的工具。这不仅仅得益于越来越强的模型能力,也得益于许多在应用/交互上的创新与探索。

工具形态

传统工具的替代品

搜索、文档工具、设计稿代码生成工具等

多智能体自然语言编程

代表工具:gpt-pilot、gpt-engineer、MetaGPT

这类工具直接通过自然语言与一个多智能体系统进行交互,多智能体系统会在内部划分多个角色/任务,如:程序员/开发/调试、架构师/设计、产品经理/拆解、项目经理/任务管理等。

但这类工具要将整个工程从新建到功能推进完全交给智能体维护,用户只能通过指令和自然语言对话对工程进行控制,并且受限于上下文窗口,也无法构建复杂的平台系统。使得这些工具都没有办法用于程序员日常的生产和实际的项目里。只能作为非专业人士的玩具,或者 AI 研究的尝试。

blog.pythagora.ai/2023/09/04/…

github.com/geekan/Meta…

代码辅助工具

代表工具:Github Copilot、Continue.dev、MarsCode

以 continue.dev 为例,这类工具的核心功能大概就是上述几类:以代码块/文件为上下文 Chat、Tab 自动补全、选中代码块进行自然语言编辑、在聊天框中对代码块进行应用。

这些功能已经在开发中被普遍使用了,开源的模型、插件对这些功能的支持也非常成熟了,不同公司内部也有类似的解决方案。

好用,但还不够强大,伴随着这些功能的组合和深度优化,期待更具生产力的工具逐步被开发出来并具备商业价值。

🆕 复合型编程IDE/插件

代表工具:Cursor、Windsurf、Cline

暂时无法在飞书文档外展示此内容

Windsurf 的 Cascade 工具,与 Cursor 的 Composer 类似

暂时无法在飞书文档外展示此内容

在代码辅助工具的基础上进一步增强代码整合能力,包括:

  • 能够有效汇总上下文
  • 能够将代码块转化为文件变更,以支持同时编辑多个文件
  • 能够管理AI批量编辑后文件的暂存状态,并灵活跳转到不同历史编辑版本

这些能力大大提高了使用体验和生产效率。

Cursor 在刚出来的时候也收获了大量非程序员以及博主们的力捧,但我们在日常使用中依然不多。主要还是因为无法直接用于我们日常工作的工程,但随着一些使用方法的探索,以及模型能力/上下文检索能力的进一步提升,越来越多的编程人员开始在日常工作中使用,以提升生产效率。

而下面的最佳实践也提供一种日常使用的工作流,该工作流基于文档,构建长期 AI 可维护的大型项目。

🆕 快速原型构建机

代表工具:v0、Bolt.New

其实和上面的 IDE 差不多,但更多的结合了 WebIDE 和 WebContainer 的优势。

常规的使用场景是:对于完全没有技术背景的角色来说,他们不知道如何为自己的需求创建一个工程并完成前期的原型验证工作,而这类 WebIDE 工具则提供了一个完美的平台让他们来得到一个可供开箱即用的工程原型。

在此之后,则可以将这个工程下载到本地,使用 Cursor 继续进行工程的后续维护和开发。

最佳实践

虽然新的工具将功能可用性提升了,但存在短板,需要结合一些使用的条件和方法:

  • 需要应用一整套闭环的工作流。其重点在各种文档的记录上(项目记忆力),让人和文档交互,完善设计,才能最终让 LLM 能更好的结合上下文生产出可靠的代码。
  • 不是所有场景都可以。逻辑性代码完全没问题,但无法还原视觉设计的细节。因此,在工作流中,通过 AI 完成逻辑代码的部分后,样式工作还得需要前端工程师来编写。
  • 技术栈和工程结构有限制。需要选择用比较老,且主流的技术栈和版本,社区训练物料比较多。
  • 合规和数据安全问题

工作流

暂时无法在飞书文档外展示此内容

这个工作流以文档为核心,适时的对项目和变更进行总结,以便在新增需求的时候,工具可以获得足够且精简的上下文,来精确生成新需求所需要的设计和代码。这些文档包括:

  1. 整个项目的描述,包括:项目简介、技术栈、文档结构、项目架构等全局信息
  2. Feature 文档,以及每个 feature 的技术实现细节
  3. 模块文档,在每个模块下,对于该模块代码的总结和索引

本质上讲,现阶段,如果我们将 AI Coding 工具拟人化,它有很多缺点:记忆力差,没有从代码仓库中持续积累特定于此仓库的经验,相当于每次找了一个新人来开发

所以,一个好的工具、开发者,应该是能够合理组织和提供一个指令足够的上下文的;一个完全适配的项目应该是,简单化模块化的原子能力 + 清晰的模块声明。类似于微前端、微服务这种组织形式可能更有利于文档的组织

未来展望

工作模式

这有一篇来自于红杉资本的文章中文版)描述了他们对生成式AI发展的展望:

虽然我对这个发展路径以及时间节点存疑,但也同样让我在想,结合AI,未来的编程是怎么样的呢?什么样的目的、什么样的实现方式、什么样的产品形态呢?往大了想,这些都太难回答了

但在一些小点上,站在程序员的角度,还是有一些想象的:

  • 近几年,就可以看见程序员的能力模型要求会有一些变化,更有 AI 辅助经验的,在生产力上会比纯手写更有优势

    • 就像在应用层,高级语言编程替代汇编语言(wiki: 编程语言世代
  • 语言本身的学习变得简单,当 JS 开发工程师想使用其他语言时,变得没什么门槛

  • 没有合规模型工具的公司,在生产力上会落后,人也一样

  • 会出现便宜的 AI 辅助编程解决方案,例如:

    • 针对特定技术栈的小参数 LLM ,本地 32G 内存的笔记本,就可以足够提升生产力了

更远的未来,如果产品形态和生产方式发生质的改变:

  • 那人可能可以专注于新的领域的扩展,而不用纠结于现有生产工具的熟练度
  • 模型越来越强大,实现这件事,真的可能会变成**「念咒语」**

工具链

现在的技术栈、工程化等方式还是基于人来建设的,但当 AI 编程占领主导之后,会有什么样的工具链来驱动呢?什么样的工具链对于 AI 编程更友好。

例如上面提到的最佳实践的工作流来说,整体围绕文档驱动,在生成文档的时候,我们也需要将项目代码转换成上下文提供给大语言模型,而 github.com/yamadashy/r… 就是一种可以将仓库代码打包成 LLM 上下文的工具。

这块还处于特别早期,因为具体的工作流还没有固化下来,但可以预见,会有越来越多的工具链产生。

推动测试进程

为什么做单元测试

LLM 生成单元测试代码,在 22 年底就已经取得了非常惊艳的效果:用例工整,分支覆盖详尽,mock 数据齐全。生成的用例如果可用,提交到仓库后会成为代码资产的一部分。如果用例有问题,这部分代码也不会直接影响到生产环境。所以,AI 单元测试是大语言模型第一次尝试落地工业生产环境的完美试验场景。

另外,单元测试本身就是研发环节中非常重要的一部分。全面的单元测试可以辅助发现很多变更引起的风险和问题。业界知名的开源软件必定包含大量自动化运行的单元测试,这在多人协作开发过程中至关重要。

我们在 23 年也尝试过使用 AI 生成单测。但是当时代码报错多、人工修复成本大,初步尝试的结果不尽如人意,于是暂且搁置了。24 年中,在业务痛点的驱使下,我们重启了 AI 单测的调研。这一次我们找到了新的角度,解决了报错多和人工修复成本大的问题,让大家看到了 AI 单测落地的可能性。

AI 单元测试效果如何

衡量标准

在讨论效果如何之前,首先要讨论如何衡量效果。怎样算效果好,怎样算不好?

在 23 年的调研中,我们没有具体的评判标准,只通过看到的结果得出一些主观判断。在 24 年的实践中,我们尝试以客观的评判标准为主,主观的感受为辅。

由于评判标准是为了服务于我们的目标,所以我们先花了点时间思考我们到底希望 AI 在单测这件事上做什么?我们希望通过引入 LLM,对编写单测提效,同时通过大量单测代码的引入,提高 bug 召回率,提升代码质量,减少线上bug。

基于这个目标,最终我们梳理出这样的指标体系:

  • 核心指标:bug 召回率

  • 准入指标:单测可执行率,单测覆盖率

  • 过程指标

    • 千行代码用例数、测试用例独立性
    • 单测执行时长
    • 项目渗透率、单测可维护性、研发接受度
    • 单测生成速度

实际效果

所以实际效果如何呢?

横向观测下,我们对比生成 case 数、case 可执行率以及单测覆盖率。大部分模型都基本满足了准入要求,小部分模型可能由于发布时间较早或提示词适配度低等工程原因,没有取得可用的表现。

在解决了一些难点问题后,我们在试点仓库做了推进。对于千行以下源码的小批量生成,单测可用率保持在 100%,覆盖率保持在 80% 左右。随着源码复杂度增加,可用率和覆盖率均略有下降,但整体表现已经进入了值得期待的状态。

除了客观数据,研发接受度(主观感受)也很重要。通过阅读我们看到,纯逻辑类函数 AI 可以做到考虑各种情形、针对特定情形 mock 数据并给出断言;React 组件 AI 可以做到考虑多种情况,并且试图在渲染的 dom 结构中寻找关键元素做断言,配合人工矫正生成部分快照可以低成本、高覆盖率完成单测编写;同时AI在边界条件测试上比人类更加严谨,也会通过函数名、注释等信息发现人类考虑不周之处。

从效率和 bug 召回的角度,我们都看到了希望,因此,我们开始在部门内推进 AI 单元测试。

为何会取得不错的效果

首先,我们标准化了基础设施,解决了一些基建问题。如果一类 case 有 3 种写法,那么我们选择其中一种我们最希望的写法让 AI 固定下来,同时帮 AI 打通任何调用 API 上的难题。

其次,人类程序员要懂单元测试。比如 Arrange-Act-Assert 单测组织方式,如何 mock 数据,如何模拟交互,如何合理断言等等。如果人本身不擅长做这件事,也就没办法更好地评判 AI 做的好还是不好。这和使用 LLM 学习其他领域、进行创意启发等场景是不同的,人类必须是相比 AI 更专业的角色。

最后,靠业界优秀论文和公司内团队支撑。从 23 年到 24 年,在 AI 单元测试领域出现了不少靠谱的论文,比如 Meta 的 arxiv.org/pdf/2402.09… 团队也是在调研之后,和公司内 Codeverse 团队一起,在我们的业务上落地了 AI 单元测试能力。

AIGC 落地的最后一公里

AI 单测真正的落地,还需要业务仓库研发同学的协助,不只是基础配置和存量单测代码的合入,还有对后续日常研发流程的改变。

接入AI单测能力,我们提供了3个模块:基础依赖包、流水线配置、ut-helper 本地工具。由于模型在纯函数上表现更好,在组件上略有欠缺,所以我们的推进策略是优先覆盖 P0 仓库的所有工具函数和通用组件,对于业务属性较强的代码暂不推进。 通过收集流水线上报的单测执行数据,我们建立了数据看板,展示仓库接入率、全量覆盖率、增量覆盖率和单测执行失败明细。

对于研发日常的影响,大家问的最多的问题是:生成了 case 之后还需要人类 review 吗?AI 有帮助人类纠错的能力吗?我们希望是不需要人工介入且能帮助发现潜在风险,而且也看到了一些希望。随着存量代码的覆盖完成,增量代码的覆盖更是对人类和 AI 的协作的考验。 AI 是否真的能在研发需求迭代过程中帮助研发规避潜在风险,部门的 bug 估分比是否能切实下降,这都是我们拭目以待的事情。

总结

在今年的实践中,我们对于 AI 落地这件事又有了更多的认识。它不是人类驱使一个远远强大于自己的怪物,落地工业生产场景仍然是靠人类往前迈一步,指挥 AI 在指定范围内做事。另外,AI 在真实落地中的挑战远不止模型训练,AI 和 AI、AI 和基建之间,有很多工程化的工作,它们可以在很大程度上改变最终的效果,有时会比实验室的大模型 pk 榜有趣得多。

保障交付质量

交付质量(Delivery Quality)是指在软件开发过程中,最终交付给客户或用户的软件产品所具备的质量水平。它涵盖了多个方面,包括功能性、可靠性、性能、安全性、易用性、可维护性等。交付质量的高低直接影响到用户满意度、产品市场竞争力以及企业的声誉。

交付质量对于软件开发非常关键,交付质量的劣化会带来用户满意度下降、维护成本增加、品牌声誉下降等问题,甚至会缩短产品的生存周期。交付质量的保障覆盖软件开发过程的所有环节,包括需求设计、开发、测试、发布上线、运维阶段,本文将从各个软件开发环节展开聊一下质量保障的手段和能力。

质量保障传统手段

完整的质量保障策略,需要各个阶段的努力,常见的质量保障框架包括基础的规范、工具、质量防控手段和质量度量:

暂时无法在飞书文档外展示此内容

保障交付质量是一个比较大的话题,交付质量问题可能出现在软件的开发生命周期任意一个环节,需求设计环节中的需求逻辑问题、开发阶段的代码质量问题和测试阶段的测试漏放问题最终都会导致交付质量的下降,常见的质量保障手段和理论基础:

  • 持续集成/持续交付(CI/CD) :持续集成(Continuous Integration, CI)和持续交付(Continuous Delivery, CD)是一种自动化软件交付流程的方法,通过频繁地集成代码、自动化测试和部署,确保软件始终处于可发布状态。

  • 测试驱动开发(TDD) :测试驱动开发(Test-Driven Development, TDD)是一种开发方法,要求开发者在编写功能代码之前先编写测试用例。通过这种方式,确保代码在开发过程中始终符合预期,提高代码质量和可维护性。

  • 行为驱动开发(BDD) :行为驱动开发(Behavior-Driven Development, BDD)是一种协作开发方法,通过自然语言描述系统行为,确保开发团队、测试团队和业务团队对需求有共同的理解。BDD 强调从用户的角度出发,编写可执行的测试用例。

  • DevOps:DevOps 是一种文化和实践,旨在通过开发(Development)和运维(Operations)团队之间的紧密协作,实现快速、可靠的软件交付。DevOps 强调自动化、监控和反馈,确保软件在整个生命周期中保持高质量。

  • 质量保证(QA)自动化:质量保证自动化工具和框架可以帮助开发团队自动化测试流程,确保软件在不同环境和配置下的稳定性和可靠性。

大模型如何赋能

LLM 的越加成熟为交付质量的提升带来了更多的可能,在整个软件开发生命周期过程中,当前阶段下 LLM 想要替代某一个角色的所有工作还不太可能,但是 LLM 已经可以在各个阶段为各个不同角色带来正向的作用,以下是一些相关的实践参考:

代码生成与重构

LLM 可以根据上下文生成高质量的代码片段,或者重构现有代码以提高其可读性和性能。代码生成能力在 LLM 上的应用和探索已经有较久的时间,随着模型能力的增强和各种工具的诞生和强化,代码生成和重构能力已经达到一个基本可用的状态,同时也有更多的专门为代码而生的代码模型逐步问世,例如 Claude 3.5 Sonnet、CodeGemma、Code Llama、Codex。

相关实践:

  1. MarsCode:智能编程助手,提供以智能代码补全为代表的核心能力,支持主流编程语言及 IDE,能在编码过程中提供单行或整个函数的建议;同时提供代码解释、单测生成、问题修复、AI Chat等辅助功能,提升编码效率与质量;

代码审查自动化

LLM 可以用于自动化代码审查,帮助开发者在代码提交前发现潜在的问题。例如,LLM 可以检测代码中的潜在错误、不规范的编码风格、安全漏洞等。

测试用例生成

LLM 可以根据代码逻辑生成测试用例,帮助开发者覆盖更多的代码路径,提高测试的全面性和有效性。例如,LLM 可以生成边界条件测试、异常处理测试等。

自动化部署与监控

LLM 可以辅助自动化部署流程,确保代码在不同环境中的正确性和一致性。此外,LLM 还可以帮助监控系统状态,及时发现和处理潜在问题。

相关实践:

  • 基于日志系统的自动化归因和排障能力,能实现在大范围故障中实现智能归因,找到根因,在实践中已经取得较好的效果;

团队协作与沟通

LLM 可以促进团队成员之间的协作与沟通,例如通过自动生成会议纪要、任务分配建议等,帮助团队更高效地协同工作。

相关实践:

  1. 飞书智能伙伴:飞书智能伙伴在群聊、会议、邮件等多个办公场景提供智能化能力,极大的提高了工作效率和沟通协作的质量;

展望大模型可探索方向

LLM 能力在交付质量保障中展现了较为强大的能力,在代码生成、代码审查、测试用例等方向已经存在一定的成熟度,但仍存在较大的潜力:

  1. 准确性提升:通过不断优化模型、训练策略、评估指标完善等策略,不断提升模型能力的准确性,降低心智负担
  2. 业务定制化:与现有的完善保障体系进行工具和流程的集合,融入业务特性,建设个性化 Agent,降低 LLM 使用成本,提升 LLM 能力覆盖度
  3. 全流程自动化:探索 LLM 自动化保障体系,管控整个需求开发周期,能够在各环节产物进行自动化质量保障,例如需求文档质量保障、代码质量问题回捞、用例质量保障、发布过程保障、线上运维监控等功能串联,形成全套的自动化方案

当前的发展现状来看,LLM 的能力还是在融入当前成熟的质量保障框架能力中,提升软件研发生命周期的的效率和质量,长期展望来看,LLM 会逐步接管质量保障的各个环节,实现高度自动化

运维管理优化

为什么需要更智能的运维

研发同学的一天,可能大部分时间不是用于开发,而是在处理各种各样的信息,比如告警、oncall、指标异常等等。

从这个角度讲,日常的运维管理比单纯的开发占据了研发的更多时间。从时间分配的角度上来说,对运维提效,可能比对开发提效相比带来的体感提升更大。

传统运维从触达渠道上可以分为两种方式:

  1. 系统日志上报,总结出各种各样的指标,当指标出现异常时,发送告警给研发
  2. 某系统 fatal 故障,发送卡片让研发检查自己负责的服务是否有问题

在这种模式下,主要的痛点在于:

  1. 收到这些运维管理的信息后,研发需要查询大量的上下文,然后定位问题
  2. 对于没有达到告警阈值的一些 case,缺乏触达能力

在这个基础上,我们需要更智能的定位分析能力和数据处理总结能力

AI 落地的思路

  • 如何处理海量的数据

用 AI 来分析海量数据,一个痛点就是受限于现在AI的语境,无法直接把所有数据都扔进去分析。

但是如果用知识/向量库的方法,用 RAG 的方法去分析,又无法让 AI 站在所有数据的角度去分析。

所以其中的一个思路是把分析的步骤拆解,一次给出局部的数据,然后通过对局部数据的分析,一步步缩小数据范围,在这个范围内给出更大体量的数据,然后让其做出更具体的分析。

  • 更多的上下文

在AI对于数据分析的基础上,查找出对应数据的上下文,然后让其总结分析。

比起单纯的数据类告警,这种分析帮助研发节省下在不同平台里查询上下文的时间。举个例子,当我们收到监控平台报警,可能会需要去埋点上报、流量监控等多个平台再去找一些次级数据,验证一些初步的判断。

在这一步,如果可以自动化的取到数据,然后给出初步的分析,就可以提高处理的效率。

  • 趋势性的数据分析

把尽可能多的数据提供给 AI,相当于 AI 帮我们观察了各种 dashboard,比如很多需要人去分析得出的尖刺,就可以让 AI 去查找。相比写死代码去分析,更加的智能弹性和节省开发人效。

数据分析的实践

目前 PAI 的数据分析,即遵循了这样的步骤:

  1. 观察整体PAI数据,分析出有尖刺的时间点
  2. 在有尖刺的时间点内,给出PAI的全部次级数据,深入分析
  3. 基于次级数据查找监控平台的对应错误,提供上下文以供分析
  4. 总结所有分析,并结构化数据发出推送卡片

暂时无法在飞书文档外展示此内容

出现演练事故,PAI的报警给出了准确的时间段,并在这个基础上给出了具体的usecase和场景,甚至具体的API。LLM能较大提升分析的效率,仅目前的分析看时间抓取的准确率达到100%,后续增加多数据源的输入(事故通报,上线记录,更多slardar错误抓取等)能增强分析的深度。

总结

一个很深入的感受是,随着 AI 能力的进化,对 AI 的使用反而应该更加的精细。

使用他要像对待一位新加入的同事,如果要让他负责你日常中的运维管理工作,你需要注意:

  1. 整理好并清晰的告诉他做这件事的步骤
  2. 准确的提供数据和保持语义化
  3. 尽量充分的上下文

结语

回顾过去的一年,AI 技术的飞速进步已深刻改变了团队的工作方式,也让我们逐渐认识到,AI 早已不再是遥不可及的梦想,而是我们日常工作中的得力助手。

团队在过去一年中对 AI 进行了大量的探索和研究。AI 单测的引入如及时雨,不仅提升了测试覆盖率和代码质量,还显著减少了人工修复的成本。AI 在内容审核领域极大地提升了审核员的工作效率和质量。在开发效率方面,AI 提供了全方位的智能代码补全、自动化代码审查、代码生成与重构、代码审查自动化等能力,涵盖了开发的方方面面,这极大的提升了我们开发效率。尽管目前 AI 相关工具还有很多不足之处,但 AI 发展的速度已让我们不得不正视起来。未来,当模型的准确性进一步提高,AI 有望在开发和生产的各个环节中提供更加全面和高效的解决方案。

技术的进步和应用场景的拓展,预示着 AI 将在我们日常开发中扮演愈发重要的角色。通过与 AI 的协作,我们相信技术生产力将达到新高度,为用户带来更好的体验。在这场技术革命中,我们迎风破浪,勇敢前行。以更开放的心态拥抱变革,用创新推动技术进步。每次新场景的落地和应用,都是团队智慧与汗水的结晶。

未来已来,我们早已做好准备,你呢?

仅需3步,稳定快速!火山引擎边缘大模型网关全面支持DeepSeek系列模型

DeepSeek 作为大模型新锐,凭借其在算法、架构及系统等核心领域的创新突破,迅速获得业界瞩目。在巨大的热度下,面对海量请求,越来越多用户遇到了请求失败、调用超时、结果无法返回等稳定性问题。

火山引擎边缘大模型网关通过一个 API 接入多家模型服务,利用全球边缘节点就近调用,提升响应速度;支持故障自动切换、重试和超时控制,确保服务可靠性;兼容 OpenAI 接口标准,可快速集成 DeepSeek 等模型,降低接入成本。

目前,火山引擎边缘大模型网关已全面支持 DeepSeek 系列模型,可通过两种方式进行模型使用:

  • 一是通过平台预置模型, 边缘大模型网关新增由火山方舟提供的 DeepSeek R1、DeepSeek V3、DeepSeek-R1-Distill-Qwen-7B/32B,您可直接使用并对其创建网关访问密钥,无需与三方模型提供商交互;

  • 二是通过自有三方模型, 边缘大模型网关新增由 DeepSeek 开放平台提供的 DeepSeek R1、DeepSeek V3 以及火山方舟提供的 DeepSeek R1、DeepSeek V3、DeepSeek-R1-Distill-Qwen-7B/32B,您可以将您在第三方模型平台的密钥纳管至边缘大模型网关,以实现通过边缘大模型网关签发的网关访问密钥进行对应模型的访问与调用。

01 3 步快速调用 DeepSeek

火山引擎边缘大模型网关支持通过一个 API 接口访问多家大模型提供商的模型与智能体,在端侧基于遍布全球的边缘计算节点就近调用。利用边缘云基础架构优势,提高模型访问速度,为终端用户提供更快速、可靠的 AI 服务体验。

在接入大模型的同时,通过配置调用顺序、自动重试、请求超时等能力,能够实现模型调用失败自动请求备用模型、单次请求失败自动重试、单次调用响应时间配置。通过产品化的配置,您可以迅速创建出与 OpenAI 的 API 和 SDK 完全兼容的网关访问密钥(API),并通过选配 DeepSeek 模型进行调用,节省大量适配成本,快速完成业务接入。

Step1 选择 DeepSeek 调用方式

调用平台预置 DeepSeek

边缘大模型网关的平台预置模型中上新了由火山方舟提供的 DeepSeek 模型,您可通过登录产品控制台查看支持模型,并通过点击创建网关访问密钥进行勾选。使用平台预置的模型 DeepSeek,您无需与模型提供商进行交互,可以直接通过边缘大模型网关进行模型配置与调用。

调用自有三方 DeepSeek

如果希望使用在火山方舟平台或 DeepSeek 开放平台购买的 DeepSeek 模型调用额度,您需要通过在边缘大模型网关平台创建对应模型提供商的调用渠道,在创建调用渠道时,需要提供您在第三方模型平台的密钥,同时勾选大模型以明确当前调用渠道可进行调用的模型配置。

完成调用渠道配置后,您可通过创建网关访问密钥勾选对应的 DeepSeek 模型,实现大模型的快速调用。

Step2 创建网关访问密钥

完成前序的 DeepSeek 模型选择后,您可在网关访问密钥创建的第二步进行模型调用配置,以更好地保障在终端业务调用时的稳定性。

  • 通过设置调用顺序,您可以手动调整上一步选择的模型调用顺序,可以根据不同厂商的容灾策略以及不同尺寸模型的降级进行设置,在前一个模型调用失败后,大模型网关将依次调用后续模型,直到成功调用一个模型。如果所有模型都调用失败,则返回错误响应。

  • 通过重试次数,您可以设置对一个模型进行调用的最大重试次数。当一个模型调用失败后,大模型网关将重新尝试调用此模型,直到重试次数耗尽。

  • 通过启用缓存,大模型网关会就近调用结果返回在边缘节点,从而加快重复查询、缩短响应时间并降低成本。

  • 通过设置**缓存的保留时长,**一旦超过指定时长,缓存将被清除。

  • 通过请求超时定义,您可以设置单次模型调用的超时时长,模型请求发出后,若在超时时长内未收到响应,则判定该请求失败。

Step3 服务调用与观测

当您根据上述流程完成网关访问密钥创建,您可以在网关访问密钥列表中查看已完成创建的信息。在详情页面,可以看到基本信息、用量统计、请求方式等详细信息。

通过详情页调用示例,您可以获得由边缘大模型网关提供的请求示例代码,包含 Curl 和 Python。当您从网关访问密钥绑定的模型中选择一个模型后,代码中的model参数值将自动替换成模型对应的值。如果网关访问密钥绑定了多个同一类型的模型,那么当选择一个模型后,可以通过单击右侧的图标查看模型故障转移的预览效果。当前模型调用失败时,大模型网关将依次调用后续的模型。在调用时,您需要将详情页 - 请求方式中的密钥替换示例代码中的$VEI_API_KEY

边缘大模型网关可根据您通过网关向模型发出的请求以及模型的响应来统计您的用量。不同模型提供商对模型用量的计量方式有所不同,根据模型调用计量方式,您的调用详情可以在用量统计中进行查看。

同时,通过云监控 - 大模型网关模块,您可以查询以网关访问密钥为维度的总用量(已消耗的 tokens 总量)与用量速率(每秒消耗的 tokens 额度)。

02 200 万 tokens 免费额度,体验边缘大模型网关

当前,火山引擎边缘大模型网关已适配 15+ 种主流大模型厂商及多个智能体提供商,点击www.volcengine.com/docs/6893/1… 了解并咨询 DeepSeek 模型~ 了解更多边缘大模型网关产品详情。

文档详情:

www.volcengine.com/docs/6893/1…

Jeddak星火计划-开启申报

摘要:抖音集团安全研究团队发起Jeddak星火计划,致力于支持安全计算领域科学研究与技术攻坚,解决机密计算、密态计算、隐私数据保护等领域的关键问题。即日起开启课题征集申报,诚邀高校及研究机构的学者报名

字节跳动观测数据埋点标准化实践

来源|字节跳动基础架构-可观测团队

背景

随着字节跳动业务规模不断扩大,对存量和新增业务的服务质量承诺变得越发关键。稳定性治理方面:怎样支持保障服务线上的高可用性,或者在出现故障/事故时,如何高效且迅速地止损、定位分析影响面已成为一个重要议题。

稳定性建设所涉及的话题十分广泛,涵盖流程梳理与标准化、数据标准化、SLO 定义、故障自愈、事故复盘和演练等方面,字节跳动基础架构可观测团队提供的稳定性平台建设思路是“事前预防、事中处理、事后复盘、事后补救/反哺事前”这四个阶段。

其中, 观测数据标准化以及一系列配套的数据链路,如:数据埋点、数据消费、数据存储、数据查询、离线数仓等,都是后续稳定性建设的重要数据基石。

并且,由此引申出排障/止损效率的问题,由于字节的服务/基础设施是分层建设的,包括端侧客户体验层、网络接入层、应用服务层、基础设施层、IDC\资源层等,不同层面的统计/描述口径是否一致、能否对应,以达到在跨层间能上卷下钻和平层内过滤聚合的“车同轨书同文”效果,这对于大幅提升整体排查效率, 让 SRE/GOC 同学能够自助完成端到端的问题排查就显得尤为重要。

img_v3_02ik_a98c1a06-a522-493e-a443-78169a1b9f3g.png

拥有统一的观测数据标准, 能够在很大程度上提升团队间的排障效率,从人工分析的方式提升至更大程度的自助/自动化排障的阶段。

埋点标准化的重要性

提高研发效率 & 降低研发协同成本

  • 面向排障方面:跨层间的上下文过滤便捷,术语统一。
  • 进行历史数仓分析(容量优化)时,整体数据处理逻辑的适配成本会大幅降低。
  • 用户的学习曲线陡峭,理解心智负担沉重。

为 AIOps 提供强有力的数据支撑

观测数据属于 AIOps 的五大基石(数据、知识、算法、代码联动、人机协同)之一。在清华裴丹老师的《AIOps 落地的 15 条原则》里,也都提及了数据的重要性。

拥有数据标准化和统一的访问体验,为后续稳定性的终极目标 MTTR 1-5-10(1 分钟发现,5 分钟响应以及 10 分钟快恢复)提供了数据层面的保障。包括同层数据的聚合 / 过滤,以及跨层数据的下钻和上卷,都会有统一的使用方式。

名词解释

名词 解释
Metrics 2.0 字节跳动内部使用广泛的时序数据处理引擎,提供了时序数据收集、存储和聚合查询的功能。2.0 版本提供引入多值概念,打平prometheus 4类指标类型语义、支持秒级打点& 存储周期定制化等多租户特性、 端到端高性能优化的分布式时序集群版本。
BytedTrace BytedTrace是字节跳动使用的一套集成了 Tracing/Logging/Metrics 三种能力的可观测性解决方案,提供了从采集、传输、存储、检索到前端产品化交互的整套能力。它定义了统一的数据模型(Trace 、Span 、Event、Metrics 等),提供了各语言配套 SDK,并与公司各主流框架组件实现默认集成。
观测埋点 TagKV Metrics TagKV 是一种用于标记和管理度量数据的键值对(Key-Value Pair)格式。通常用于监控系统、分布式追踪系统和日志管理系统等领域,TagKV 提供了一种灵活且高效的方法来分类和筛选数据。
Measurement 可观测对象的某个指标,如服务的上游调用延时,物理机的 CPU 使用率。Measurement 是带有可观测对象的 context的,是语义化的,同时能识别在不同条件下应该使用哪个版本的指标以及对应的 TagKV。而且可以根据观测对象的元数据条件,同时关联多个时序数据源,便于按需时序数据源切换。
SLO Service Level Objectives,服务级目标是指服务提供方对所提供服务的某些性能或质量指标所设定的目标值。这些指标通常用于衡量服务的可用性、性能和其他关键属性,旨在确保服务达到预期的质量水平。
TCE Toutiao Cloud Engine,为字节跳动内部提供的高度可用、弹性扩展的容器服务。
PSM Product Subsys Module,是字节跳动内部服务的唯一标识。
GOC Global Operations Center,基于字节跳动各类研发,运维体系下的高可用产品能力,结合稳定性保障策略及运营机制,提供字节跳动全线基础产品的可靠性服务与设施稳定性保障,达成字节跳动全线业务各类场景下的端到端高可用性。

字节埋点标准化挑战与拆解思路

挑战: 历史上可观测性埋点质量偏低

首先,我们对埋点标准化进行定义,包括但不仅限于如下的标准定义,包括覆盖完整、定义统一、计量准确、面向引擎友好等四大方面。

img_v3_02ik_56f19e79-13a2-4bdf-aa70-2d6d49e02b8g.png

简而言之,在 2020 年以前,从覆盖完整定义统一计量准确面向引擎友好等维度来看,字节整体的观测数据埋点存在一定的差距。

具体如下:

  • 负载均衡 埋点

    • 计量准确:中等水平

      • 存在较严重的打点丢失问题
    • 面向引擎友好:较低水平

      • 指标打点对于配置预计算不友好
      • 指标名膨胀也比较严重
  • 微服务 埋点

    • 覆盖完整:中等水平

      • 20 年前 Tracing 方案还在 V1 版本
    • 计量准确:中等水平

      • 遇到高基数的指标会被封禁
    • 面向引擎友好:较低水平

      • 指标打点对于配置预计算不友好
      • 指标名膨胀也比较严重
      • 加权计算也不好实现
  • 语言 运行时 埋点

    • 定义统一:较低水平

      • Golang & C++ 框架 不同的版本定义的指标格式都不太一样
    • 面向引擎友好:较低水平

      • 指标打点对于配置预计算不友好
  • 容器指标 埋点

    • 覆盖完整:较低水平

      • 没有日志采集覆盖
    • 计量准确:中等水平

      • 遇到高基数的指标会被封禁
    • 面向引擎友好:较低水平

      • 指标打点对于配置预计算不友好
  • 基础架构 存储 & 数据库 埋点

    • 覆盖完整:较低水平

      • 存储、数据库、MQ 客户端没有黄金指标打点
      • 没有日志采集覆盖
    • 计量准确:较低水平

      • 不同存储、数据库、MQ 产品打点格式 都不一
    • 面向引擎友好:较低水平

      • 指标打点对于配置预计算不友好

思路: 分层&向后兼容推进埋点标准化

总结来说,之前的字节服务端观测数据质量大致存在三类问题。

  • 同层数据/跨层数据不一致。
  • 观测的多模态数据类型(指标、日志、链路)的数据定义不统一。
  • 观测数据格式对引擎不够友好,例如所有数据都在 default 租户的一个大仓里,再比如很多观测指标的定义对于预计算不友好。

针对上述问题,我们采取了如下的多个思路逐一进行解决。

实施思路

一方面,在埋点侧就尽可能统一埋点 TagKV 定义,而且平台级 TagKV 都通过环境变量或者请求上下文自动注入对应的 Tag Value, 以防止由业务手工注入带来的人工错误。

另一方面,对于指标、链路和日志侵入式 SDK,我们通过字节内部的远程过程调用框架以及存储、数据库、消息中间件的客户端 SDK 搭载嵌入中间件,对于业务来说,能相对透明地升级到最新特性的版本。另外, 对于远远低于 SDK 基线版本的服务, 我们也通过字节软件供应链安全治理平台通过编译卡点的不同程度[warning 提示/发布卡点]推动业务升级。

在 负载均衡、应用、中间件、存储计算组件等各个纵向方面, 我们也主动与对应的平台对接,推动指标、日志、链路的埋点注入。

最后,在指标埋点上也额外关注对于多租户的声明,以达到一定的分库分表功能,以及多值声明,以最大程度减少数据消费和存储成本。如下所示, 就是团队在各个不同观测对象的埋点方面所做的业务推进情况。

img_v3_02ik_82b1a459-030a-43f1-8db0-808d5cc209eg.jpg

难点: 识别和解决

类似观测数据标准化的工作历经多年,牵涉的团队众多,整个过程并非毫无波折。遇到问题时要解决问题并思考能否将其标准化或者平台化,同时也要考虑能否尽可能地复用其他团队的能力和工具来助力我们进一步推广。当时如何高效地推动业务升级是我们的主要目标。

[业务推进] 高效推动业务升级观测SDK

在 Metrics SDK 需要升级到基线版本的情况下,以前的做法是在字节软件供应链安全治理平台上配置版本拦截,提醒用户升级,但是整体升级效率比较低,我们也无法跟踪用户的升级进展。因此我们联合字节软件供应链安全治理平台团队实现 SDK 自动升级功能。

Metrics ****SDK 自动升级

Metrics ****SDK 自动升级功能可以自动实现在当前业务代码库的代码提交期间,如果检测到对应集成的metrics SDK 低于基线版本,则会向用户推送代码提交失败的通知,提醒用户需要主动升级到metrics SDK基线版本或以上的操作。

远程过程调用 框架 & 基础组件客户端 集成 ****BytedTrace ****SDK 集成

观测团队多年来持续推动公司的远程过程调用 框架以及基础组件客户端 集成 BytedTrace SDK **** ****借助字节软件供应链安全治理平台进行递进式卡点推广,依靠代码血缘平台来推动框架、组件的基础库版本实现升级。在存有流量的微服务上,BytedTrace SDK的覆盖比例按照 TCE pod 接入情况来计算,当前已达到 95%。

从服务的优先级角度而言,公司当前96% 的 P0 服务中已接入 Bytedtrace SDK 。

[业务推进] 提升基础组件观测埋点质量

TCE 调度 / 运行时 打点格式设计思路

前文提到,提升业务层、应用层、容器层等多层间指标的跨层关联和下钻能力是指标标准化的一个重要目标,而实现跨层关联的关键动作在于保证同一含义的指标 TagKV 在各层上的定义保持统一,为实现这一点,我们对各个层次上的核心组件进行了统一的设计,具体如下:

层次 核心组件/着手点 埋点标准化设计思路
业务层 Metrics 2.0 SDK - 内置统一的平台级TagKV,提供横向跨语言、跨服务的TagKV统一
应用层 运行时 指标、远程过程调用 指标 - 横向上,提供统一的、跨语言的指标名定义
  • 纵向上,对齐Metrics 2.0 SDK 平台级TagKV规范 | | 容器层 | 与调度合作,对容器指标采集agent(TCE调度)进行标准化改造 | - 对齐Metrics 2.0 SDK 平台级TagKV规范 |
  1. 首先,我们在 Metrics 2.0 SDK 内置定义了一套平台级 TagKV,这样所有使用 Metrics 2.0 SDK 的业务打点都会携带标准的预定义的 TagKV。这些共同TagKV包括: _cluster、_psm、_pod_name、_ipv4 等。

  2. 在应用层,挑选了对业务排障、应用观测常用且通用的两类指标(运行时、远程过程调用)进行标准化,目标是在横向上,提供跨语言、统一的指标名、TagKV语义定义等;在纵向上,对齐 Metrics 2.0 SDK 平台级 TagKV 规范,以便于跨层关联。以 运行时 指标为例,其定义规范如下:

    1. 不同语言的指标采用统一命名约定:runtime. {runtime} . {metric}[ . {field}]
    2. 不同语言类似含义指标采用统一命名风格:如 go、java 中统计堆对象申请量的指标都命名为memory.allocated_bytes
    3. 必须包含 Metrics 2.0 SDK TagKV 规范的平台级 TagKV,如 _psm、_pod_name 等
  3. 在容器层,与调度团队共同推动其 TCE 容器指标采集 agent(TCE调度) 的指标标准化改造,指标 TagKV 对齐Metrics 2.0 SDK TagKV 规范。

通过将这些核心组件进行标准化改造,为跨层的指标关联和下钻提供了能力基础。同时,在每个核心组件的指标定义上,我们还通过以下两个方式进一步提升埋点的性能和成本收益,第一点是对各个组件使用独立租户,实现资源的隔离,保障写入稳定性和查询性能;

指标 租户名 集群类型
运行时 apm.runtime 独立集群
远程过程调用 框架 apm.rpc 独立集群
TCE 容器指标 computation.tce 独立集群

第二点是在语义明确的前提下,尽量使用多值格式定义指标,降低存储成本。以 TCE调度 指标为例,将原来 mem 相关的四个指标合并为一个多值指标的四个字段,存储成本大致可以被认为降低至四分之一。

原指标 改造后多值指标名 改造后多值字段
tce.host.mem_total inf.tce.host.mem total
tce.host.mem_free free
tce.host.mem_available available
tce.host.mem_used used

[配套工具] 帮助平滑迁移观测数据

[工具1] 语义 化指标替换

我们提供语义化指标替换,称为Measurement,其能力就是对原始 Metrics 打点的语义化封装;同时能识别在不同条件下应该使用哪个版本的指标以及对应的 TagKV。这两个关键能力能够促使在做数据迁移时,观测大盘和报警基本达到比较平滑的状态。

原始 Metrics 打点:直接写入时序数据库(可以是 metrics \ influxdb \ prometheus)的数据。

语义 封装:用标准的语义化来包装原始的 metrics 打点数据。 比如 go 服务的 gc 数量的 metrics 打点是 go.{{.psm}}.numGcs,其中{{.psm}}为具体的 psm, 我们会定制一个语义化指标名叫 "runtime.go.gc_num"来表达 go 服务的 gc 数量,包括用统一的 TagKV 来封装对应的原始 TagKV。 不管是 open api 还是前端调用, 都用指标 "runtime.go.gc_num" 对measurement 服务进行调用。

不同条件下的查询 路由:需要这个能力是因为在字节内部原始 Metrics 的打点会不断的升级, 比如 golang 运行时 历史上会有 v1 、v2 、v3 多个版本,我们需要能够在给定的输入信息条件下去查询到对应的指标版本。这个判断条件实现的逻辑一般为可用输入的 psm 名字构成 Metrics go v1 的指标名,再根据指标名的数据是否存在来判断是 runtime v1、runtime v2 或者 runtime v3 的版本,指标判断也以此类推。或者可以通过 psm 的 scm 编译信息确定该 psm 编译的 golang 运行时 版本是 v1、v2 或者 v3。 通过对应条件的判断来做到对应数据的查询路由。

img_v3_02ik_5c2f9415-8b2f-4dd5-a031-dff8ce63af6g.png

在有了 Measurement 能力后,我们抽象出了 Measurement 服务,该服务作为观测大盘和报警的一个数据源。在尽量不需要用户介入的情况下完成数据打点的迁移和替换。

当前借助 Measurement 能力,针对公司的 远程过程调用、HTTP 等框架,容器引擎、FaaS、机器学习推理等平台,还有负载均衡、缓存、数据库、消息队列等基础组件,以及golang 运行时 等,均进行了统一的标准化语义封装,这些语义化封装在观测平台上均有所展现。

[工具2] Metrics 前缀分流

怎样帮助业务顺利地迁移到新租户,同时确保新老指标的查询方式均可使用,是我们在推动业务租户迁移时所面临的较大挑战。

针对上述问题,观测团队起初推进引导用户主动迁移至新租户,旨在实现租户隔离,提供更优的稳定性保障,进行精细化容量治理以降低成本。然而,后来发现主动迁移的速度太慢,赶不上打点量的自然增长。于是,推出了让用户无感知的被动租户迁移方案。大致思路是依据某些特定的指标前缀,主要涵盖一级 / 二级前缀,通过特定配置把这些指标分别路由到不同的新租户,并且在新租户上支持查询翻译,即便用户不修改查询租户,继续用 Default 租户查询仍能正常获取数据。该方案具有以下优势:

  1. 业务在读写两侧无需进行代码变更,就能将流量迁移到新租户集群。
  2. 最大程度减少不同租户间因集群变更和读写流量变化对线上稳定性产生的相互影响,提供更出色的稳定性保障。
  3. 精准对接业务线租户,便于后续进行打点流量治理、容量规划以及资源充值等操作。

具体的实现由 Metrics 组件中各模块的相互配合完成,包括写入、控制面、查询、数仓等方面,大致的实现流程如下:

前缀分流租户的整个过程存在众多细节,为减少过程中的过多人为操作,防止出现某些环节被遗忘的情况,观测团队设计了分流流程工单以及白屏化运维平台,尽可能让整个操作流程实现自动化,提高分流租户的效率。此外,前缀分流迁移新租户的整个过程对于业务来说成本为零,同时对于 观测团队而言不像依赖业务方主动迁移那样周期漫长,其周期短、生效时间快,能够收敛团队人力的持续投入。

总的来说,观测团队提供了一种让用户无感知、实现无缝迁移新租户的方案,用户的核心观测大盘和报警也无需修改,最大程度降低了埋点标准化对用户的打扰。

埋点标准化字节的实践与效果

观测数据质量前后对比

经过 2020-2022 年推进 BytedTrace SDK 覆盖率、2023 年推动云基础组件和应用层指标租户迁移之后, 从埋点标准化的 4 个维度看,都有不同程度的质量提升。

  • 负载均衡

    • 计量准确:较高水平 [2020年为中等水平]

      • 通过 2.0 SDK 三个特性, 基本消除丢点的问题:

        • 打点本地聚合
        • 面向字节流的 codec 编码
        • Agentless 投递
    • 面向引擎友好:较高水平 [2020年为较低水平]

      • 实现面向预计算友好的效果
    • 成本收益:

      • Metrics 2. 0 打点商品成本相对 1.0 下降 94%
      • Metrics 2. 0 很好地解决了打点封禁问题,特别是在一些配置量巨大的核心集群,解决了其超过 90%打点无法查询的情况
      • Metrics2. 0 TLB 机器成本初步统计主容器和 adaptor 打平,同时相对 1.0 节约了 ms2 的 15000 核资源
  • 微服务

    • 覆盖完整:较高水平 [2020年为中等水平]

      • 80%以上 PSM 覆盖到 BytedTrace SDK 集成
    • 计量准确:中等偏上水平 [2020年为中等水平]

      • 高基数的指标封禁问题 由于迁移到了新租户 可以做封禁阈值定制化
      • [计划中] 升级 bytedTrace 内的 metrics 2.0 SDK 降低丢点的风险
    • 面向引擎友好:较高水平 [2020年为较低水平]

      • 实现面向预计算友好的效果
    • 成本收益:

      • 以计算关键组件 Consumer 为例,新租户只需要老租户 20%的资源,就可以完成相同数据的写入计算;其他写入计算类组件也类似
      • 以存储关键组件 tsdc 为例,新租户只需要老租户 55%的资源,就可以完成数据的写入、存储
  • 语言 运行时

    • 定义统一:较高水平 [2020年为较低水平]

      • 统一了不同语言和框架的 运行时 打点格式
  • 容器指标

    • 覆盖完整:中等水平 [2020年为较低水平]

      • TCE调度 接入日志租户
    • 计量准确:较高水平 [2020年为中等水平]

      • 引入多值 降低指标名数量

      • 高基数的指标封禁问题 由于迁移到了新租户 可以做封禁阈值定制化

      • 通过 2.0 SDK 三个特性, 基本消除丢点的问题

        • 打点本地聚合
        • 面向字节流的 codec 编码
        • Agentless 投递
    • 面向引擎友好:较高水平 [2020年为较低水平]

      • 实现面向预计算友好的效果
  • 基础架构 存储 & 数据库

    • 计量准确:较高水平 [2020年为中等水平]

      • 引入多值 降低指标名数量

      • 高基数的指标封禁问题 由于迁移到了新租户 可以做封禁阈值定制化

      • 通过 2.0 SDK 三个特性, 基本消除丢点的问题

        • 打点本地聚合
        • 面向字节流的 codec 编码
    • 面向引擎友好:中等水平 [2020年为较低水平]

      • 打点格式调整的 支持预计算配置
    • 成本收益:

      • 以 mysql 迁移为例

        • Mysql 租户 成本节省 45.7%
        • Mysql 租户 带宽节省了 80%

截止到今年年初, Metrics 在中国国内区域已经接入 60+ 租户,占总流量的 70% 左右。

赋能效果总结

加速微服务端到端根因定位

通过指标标准化 & 多模观测数据 [指标, 日志,链路]标签术语的标准化, 我们实现面向微服务的上卷 & 下钻关联分析。

也使得使得跨层问题根因分析有了可能性:

目前端到端根因定位覆盖了60%以上的报警场景,日均触发根因定位 50余万 次,用户对定位结果的正反馈率超过80%。

简化服务性能离线数仓构建

在实现了在线观测数据的标准化,并将其导入统一的存储介质之后,构建字节整体关于服务性能、容量、吞吐量的数仓大盘就更加便捷。比如 展现某服务的单核 QPS 分时热力图 如下:

目前基于微服务应用性能数仓已覆盖公司超97%的微服务量化,有效支持字节跳动各业务线服务性能、服务应用健康度度量,由此带动一系列精准的成本优化。

观测底座自身收益

  • 从稳定性角度看,由于引入metrics多租户概念,所以我们能够通过逻辑租户映射到物理资源,从而降低故障半径,减少不同租户间流量的相互干扰。
  • 从成本角度看,我们能够依据每个租户的副本数、存储时长 TTL、打点的最小精度以及多值定义,最大程度地降低写入流量和存储容量的成本。metrics 多租户迁移前后对比,成本节省幅度在 20% ~ 80% 不等。

总结

历经上述观测埋点套件 BytedTrace SDK推广、Metrics 指标标准化迁移和推广、部分业务接入日志多租户,字节后端观测数据的质量在覆盖完整度定义统一计量准确面向引擎友好四个方面上取得了显著的质量提升。这也为后续的全景全栈高效排障奠定了坚实的基础,帮助更多业务团队在业务稳定性方向持续建设。


依托字节跳动内部可观测团队大规模技术实践,通过内外合力,在火山引擎上推出了应用性能监控全链路版(APMPlus)、托管 Prometheus(VMP)、云监控等可观测产品,致力于为用户提供全面、智能、高效、易用且安全的全栈可观测解决方案。

目前 APMPlus Server 端监控已正式 GA 并支持最新的大模型链路追踪相关能力,欢迎咨询了解。

🔗 相关链接

APMPlus www.volcengine.com/product/apm…

VMP www.volcengine.com/product/pro…

云监控 www.volcengine.com/product/clo…

详解veImageX助力卓特视觉智能、高效生成设计素材

前言

设计素材行业为设计师和创意工作者提供丰富的视觉和创意资源。数字媒体和互联网的迅猛发展,促使这一行业市场规模不断扩大,用户对设计素材的个性化和定制化需求与日俱增。卓特视觉,作为Adobe Stock中国区官方合作伙伴,自2014年成立以来,始终致力于推动中国创意产业的繁荣发展。在AI的技术浪潮中,卓特视觉选择与火山引擎veImageX(一站式图片解决方案)携手合作,旨在通过AIGC加成,更加智能和高效的生成设计素材,进一步拓宽创意表达的边界。

卓特视觉(Droit Vision),Adobe Stock中国区官方合作伙伴,全面整合全球范围内的高质量图片、矢量插画、高清视频及音效音乐等素材资源,专注于为新媒体、设计、广告、各类垂直行业及个人用户,提供一站式的视觉素材和解决方案,助力创意人士和企业提升其视觉作品的品质和影响力。

至今,卓特视觉在线销售高清正版图片总数超5.6亿和超3,600万条高清视频。自2014年成立以来,卓特视觉成功为众多知名企业提供了安全、高效、优质的视觉创意解决方案,赢得了广泛的企业级客户信任。

场景概述

在设计素材行业,传统的商业模式通常由创作者提供内容并上传至平台,平台负责销售和分发,同时负责版权等问题,用户通过付费获取平台的高质量素材资源,平台则根据销售情况与创作者分成。而在AI的技术推动下,平台会提供一系列的AIGC工具,帮助用户实现图片生成、放大、扩展、风格转换等效果,同时收取使用这些功能的费用。

图片来自卓特视觉官网

方案介绍

火山引擎veImageX基于字节跳动的图像领域最佳应用实践,提供端到端的一站式图片解决方案。

整体架构

一套方案解决上传、存储、图像处理、分发、解码、QoS&QoE监控的全链路方案,覆盖从内容生产端到图像消费端。

veImageX的服务端具备强大的实时处理能力,不仅包含了裁剪、缩放、格式转换等基础图像处理功能,还提供了画质评估、画质增强、智能裁剪、超分、盲水印等丰富的AI组件能力。

卓特视觉接入了veImageX的哪些能力

一、画质评估

画质评估组件支持模仿人类对于图像的视觉感受,从而对图像的各方面进行评分。评分指标有大众美学评分、噪声强度评分、纹理丰富度评分和色调均衡程度评分等。veImageX通过抖音集团内部的大量线上业务实验发现,图片画质优劣对点击率、停留时长等消费类指标有正相关影响,间接影响用户收益指标。卓特视觉通过画质评估组件,对线上的海量素材文件进行了广泛的评估,在网站尽量展示评分较高的图片,并在用户查询图片时,优先推荐同类型中评分高的图片。这一系列举措不仅提升了网站整体的图片质量及用户的满意度,还促进了业务增长,并获得了良好的用户口碑。

二、智能裁剪

智能裁剪是 veImageX 提供的全新图片裁剪附加能力,支持对输入图片进行指定尺寸变换,能够自动判断主体区域的位置,并支持自动化适配不同尺寸图片内容的裁剪。卓特视觉的用户分布在各行各业,用途包含宣传页、海报、杂志、电商平台、户外广告等,对图片的尺寸和表现侧重点都有个性化的要求,卓特视觉通过智能裁剪能力批量对原图进行裁剪,自动化适配用户对于不同尺寸的要求,同时确保在任何尺寸下,图片主体都能处于最佳位置。快速高效满足客户需求的同时,也拓宽了产品的适用边界。

三、存储

卓特视觉目前拥有超过5.6亿的正版素材,并且数量仍在持续高速增长,占用的存储空间日益庞大,成本也与日俱增,veImageX提供存储服务,同时支持根据上传时间变更存储类型的智能降冷策略,有效节省存储的成本。此外, 为了进一步帮助企业降低存储成本,veImageX通过自研BVC算法,提供全球领先的极限图片压缩比,对比JPEG压缩率提升8-10倍,在不降低图片质量的前提下,在保持图片清晰度基本不变的情况下,单张图片体积节约超过70%,可以实现显著的成本节约。

四、分发

veImageX作为端到端的图片解决方案,除了强大的AI图像处理能力,还提供存储和分发能力,在分发阶段,veImageX利用自建 CDN 节点进行灵活的智能调度,为国内外用户提供极致的观看体验。卓特视觉通过使用veImageX的高效分发方案,确保了全球用户访问的快速和稳定。

设计素材行业其他需求的能力

一、智能生图能力

用户在平台可能会遇到不符合设计标准的素材,不仅影响了创作效率,同时也会影响平台的口碑,因此,引入AIGC智能生图能力显得尤为重要,当现有素材无法满足需求时,可以通过AIGC快速生成。veImageX结合豆包的AI生图方案,最新上线了智能生图能力,封装了文生图、图生图一站式解决方案。支持将豆包生成的图片进行后处理,包含存储、压缩、二次处理、超分辨率、盲水印、裁剪、适配、分发等。典型功能如下图展示:

  • 文生图场景

  • 图生图场景

此外,veImageX智能生图能力还支持桥接第三方模型文生图、图生图服务,直接对接veImageX进行上传、编码、存储与管理,并支持完善的后处理服务。大大扩展了方案的灵活性。

二、智能审核

设计素材平台如果遇到涉黄、涉暴的素材上传,不仅涉嫌法律风险,而且对平台的品牌可信度将会是极大的折损,而面对每天数以十万计的素材,人工审核显然无法满足。veImageX 提供了图片智能审核功能,支持分类型智能检测图片中涉黄、涉暴恐、违法违规等十几种禁用行为,并返回最终识别结果。识别并预警用户上传的不合规图片,协助平台快速定位处理。

三、盲水印

在设计素材行业,素材的版权归属一贯容易产生争议。在版权意识和版权法逐渐完善的今天,稍有不慎可能就会产生法律纠纷。veImageX兼顾版权追踪和图片美观,支持对图片添加盲水印,同时支持对图像提取盲水印信息,方便追踪溯源。盲水印是一种肉眼不可见的水印方式,可以在保持原图图片美观的同时,又可以保护资源版权。对原图进行解码后,可以得到盲水印信息证明图像的版权归属,避免未经授权的复制和拷贝而造成的版权问题。

四、超分辨率

设计素材平台的用户在制作海报、广告牌等场景时,往往需要对原始素材进行放大,同时需要保持放大后图像的清晰度,即所谓的“无损放大”。veImageX支持将图像做2-8倍智能放大,并保持处理后图像的清晰度,使图像更加清晰、锐利、干净,给用户带来良好的视觉体验。

五、智能背景移除

用户在使用平台提供的设计素材时,如果发现图片中的主体部分符合需求,但是为了配合使用场景、符合品牌调性等原因,需要对原始图片中的背景进行移除。veImageX的智能背景组件,支持保留图像的主体并抠除其复杂的背景,从而生成保留主体的透明底图片。veImageX提供了多种图像处理模型,支持精细化图像主体轮廓处理,可大幅度提升图像处理效率,降低人工成本。

结语

在AI的技术浪潮中,传统的设计素材行业正在向AI时代迈进,以满足客户日益个性化、精细化、创意化的诉求。火山引擎veImageX凭借夯实的技术底座和强大的AI能力,与卓特视觉携手合作,共同迈入设计素材行业AI新纪元,助力我国视觉版权服务市场的蓬勃发展。

了解更多:www.volcengine.com/product/ima…

❌
❌