阅读视图

发现新文章,点击刷新页面。

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.

❌