哔哩哔哩AI应用开发实习一面面经复盘
- 轮次
- 实习一面
- 时间
- 2026-09
- 来源
- 牛客网
《面试题目》
- 你做过的最复杂的 Agent 项目是什么?完整讲一遍
- 这个 Agent 的架构怎么设计的?核心模块有哪些
- 如果 Agent 需要处理千万级视频,你怎么设计处理方案
- 视频 Agent 中,FFmpeg 随机 seek 为什么需要定位关键帧
- OCR 关键帧的场景检测怎么做
- 你怎么判断 ASR、OCR 的结果是否正确
- 如果让你构建 ASR、OCR 的评测集,你会怎么做?怎么保证场景覆盖
- 如果不同场景下算法效果差异很大,怎么设计评测集和指标
- 如果把 Agent 项目移植到 B 站、要处理千万量级请求,架构怎么改?你会怎么重新设计?
- Agent 的停止条件是什么?怎么判断分析完成
- 如果 Agent 陷入死循环,怎么检测和处理
- 你的 Skill 怎么设计和实现?在大规模场景下(1000 个 Skill、每个描述 500 token)怎么做渐进式加载
- 如果模型不遵循 Skill 文档,高合规场景怎么保证不越权
- 高并发下 Agent 系统怎么扛?1000 个沙箱同时运行,资源怎么隔离
- 如果容器重启,会话状态怎么恢复
- 从技术层面讲线程池配置,核心参数怎么设置
- 什么情况会导致 OOM?怎么排查?频繁 GC 怎么优化
- Java 内存模型讲一下,volatile 怎么实现可见性
- 进程、线程、协程的区别?各适用什么场景?Goroutine 调度怎么实现
- 你的 Agent 系统支持多租户吗?数据和资源怎么隔离
- 如果 Agent 执行到一半用户退出,任务怎么处理?状态怎么保存
- 你最近用过什么新的 AI 工具?有没有觉得不错的
- 你觉得 AI 编程工具最大的瓶颈是什么?希望下一代解决什么
- 如果让你设计一个视频内容理解 Agent,你会怎么做
- 视频中的多模态信息怎么融合?音频、视觉、文本怎么对齐
- 如果让你做视频推荐理由生成,怎么保证吸引人
- 你怎么评估视频 Agent 的效果?有哪些指标
- 如果让你设计一个「视频审核 Agent」,怎么处理违规内容
- 多模态违规怎么识别?比如暴力、色情、政治敏感
- 如果让你做「视频摘要 Agent」,怎么保证摘要质量
- 长视频怎么处理?分段还是整体
- 如果让你设计一个「弹幕互动 Agent」,实时回复弹幕,你会怎么做
- 弹幕场景对延迟要求极高,怎么优化
- 如果弹幕内容违规,怎么实时拦截
- 你怎么看 AI 在视频社区的应用前景
- 如果让你设计一个「UP 主创作助手」,帮 UP 主生成标题、封面、文案,你会怎么做
- 你最近关注 AI 领域的哪些新技术
- 手撕:实现一个函数,给定一个字符串,将其中的空格替换为 %20
《参考解析》
FFmpeg 随机 seek、关键帧与 -ss 的正确用法:视频是帧间压缩,P 帧参考前面的 I/P 帧、B 帧双向参考。解码器必须先拿到所在 GOP 的 I 帧,才能解出中间的帧;少了参考帧,解出来就是绿屏、花屏或大块马赛克。所以「随机 seek」在工程上永远是两步:先在容器索引里跳到目标时间点之前的最近关键帧(demuxer 级 seek),再从关键帧开始解码到目标点。写命令时这两步由 -ss 的位置决定:ffmpeg -ss 00:01:30 -i in.mp4 -frames:v 1 -q:v 2 out.jpg 是输入 seek,跳关键帧后再解码丢弃到 90s,代价上界只有一个 GOP,现代 ffmpeg 默认就是精确的(-accurate_seek 默认开);ffmpeg -i in.mp4 -ss 00:01:30 -frames:v 1 out.jpg 是输出 seek,从 0 解码到 90s,精确但长视频基本不可接受。要特别说清的是 -noaccurate_seek 的作用:它不是「修复花屏」的开关,而是关掉「跳关键帧后再解到目标点」这一步。ffmpeg -ss 90 -noaccurate_seek -i in.mp4 -frames:v 1 out.jpg 会直接把关键帧那一帧交给你,速度最快,但你请求的 90s 与输出帧的真实 PTS 可能差最多一个 GOP——关键帧间隔 10 秒就偏 10 秒。所以它是「接受关键帧对齐」的显式取舍,不是精度修复。真正导致抽帧花屏的常见原因是另外三种:用 -c copy 在非关键帧处切,文件头部几帧的参考帧被切掉;硬解拿到不完整 GOP;以及 seek 不精确却仍按请求时间戳命名输出帧,画面没坏但帧和时间戳错位了,对下游 OCR/融合是致命的。排查顺序建议固定下来:先 ffprobe -v error -select_streams v -show_frames -show_entries frame=key_frame,best_effort_timestamp_time -of csv in.mp4 | head 看关键帧分布,确认 GOP 有多长;再看抽出的帧真实 PTS 是否等于你记下的时间戳。GOP 太大(10 秒以上)既慢又不准,工程上的标准解法是入库时转一遍 mezzanine:-c:v libx264 -crf 18 -g 30 -keyint_min 30 -sc_threshold 0 -force_key_frames "expr:gte(t,n_forced*1)",每秒一个闭合关键帧,之后所有随机访问的解码成本上界固定为 1 秒。只要关键帧可以直接 -skip_frame nokey -i in.mp4 -vsync 0 -frame_pts 1 kf_%06d.jpg。场景检测抽帧则用 -vf "select='gt(scene,0.3)*gte(t-prev_selected_t\,2)',showinfo" -vsync 0 frames/%06d.jpg,乘上最小间隔条件是为了避免滚动字幕、闪光、镜头抖动把帧抽爆。
千万级视频的异步分层与背压:千万级不可能同步处理,也不该指望一个队列包打天下,核心是三件事——分档、算得清的水位、以及入口敢拒绝。第一步先定 SLO 分档:P0 实时档(用户正在页面等着看结果,秒级)、P1 普通档(后台分析,分钟级)、P2 批量档(离线挖掘,小时级)。三档各自独立队列、独立消费组、独立资源池,物理隔离,否则一条长尾批量任务就能把实时请求饿死。数据面上,消息里只放对象存储 URI、任务元数据和 trace_id,绝不搬视频字节;Kafka 按 video_id hash 分区,分区数就是消费并行度的上限,消费者数量超过分区数纯属浪费。背压的关键是从「队列条数」升级成「预计排队时长 = 积压量 ÷ 消费速率」:设阈值(例如 P1 档预计排队超过 10 分钟,或 consumer lag 超过 5 万条)就对入口限流——要么直接拒绝并给客户端退避重试的建议,要么按视频长度和优先级降级链路(只做 ASR 不做 OCR、抽帧降到 1/5 帧率、先返回草稿摘要)。对用户要说人话:「当前排队较多,预计等待 X 分钟」永远好过静默转圈。伸缩用 KEDA 按 Kafka lag 或 Redis 队列长度做 HPA 指标,比 CPU 利用率贴近真实压力,但扩容同时受分区数和下游配额(ASR/LLM 的 QPS、GPU 卡数)硬约束,所以入口限流必须和下游配额一起设计,否则扩容只是把压力原样传导到下游再雪崩。每个阶段要幂等:以 task_id + stage 作唯一键,产物写 <video_id>/<stage>/<version>/,重跑覆盖或写新版本再原子切指针,失败只重跑单阶段。成本侧靠内容 hash 去重命中间产物 + 按热度分级路由长尾视频,盯的指标是元/千分钟处理,而不是笼统的「机器利用率」。可观测上必须盯:端到端 P50/P95、各阶段 P95、consumer lag、重试率、死信量。原帖里「上传后几小时还没出结果」这种体验问题,本质就是没有 lag 告警、也没有背压。
Agent 的模块解耦与数据契约:接入层、调度层、执行层、存储层的四层划分只是部署边界,真正决定这套系统能不能长期演进的是数据契约。原帖踩的坑很典型:抽帧器输出「图片路径列表」,OCR 模块期望「图片二进制流」,中间靠加一层转换才通——这种修法只解决了这一次,下一个模块接进来还会再撞一次。正确做法是让每个 stage 都是 manifest -> manifest 的纯函数:输入一个 manifest(引用上游资产、时间基准、schema_version、统计信息),输出一个新 manifest 和若干新资产,资产本体进对象存储,模块之间只传 manifest 的 URI 加版本号。谁生产谁负责写契约字段,谁消费谁只依赖契约里显式声明过的字段,其余一概不看。契约要版本化:schema_version 必填,只增字段、不改已有字段语义、不删字段(删除必须先经过一个双读窗口);消费端解析时忽略未知字段;Protobuf 的 field number 一旦发布就不能复用,JSON 方案则靠 CI 上的 schema 校验卡住破坏性变更,破坏性变更必须升 major 并配迁移脚本。落地手段是契约测试:把上游的样例产出固化成 fixture,在下游的 CI 里跑一遍,跨语言管线尤其需要。另外,时间语义也是契约的一部分——时间戳单位、起始偏移、以及「这一段没有 OCR 结果」到底是空数组还是字段缺失,都得写死,否则融合阶段必然对不齐。做到这几条之后,每个模块都能独立扩缩容、独立重跑、独立灰度,失败时只重跑坏掉的那一段,而不是整条链路重来一遍。
抽帧 / OCR / ASR 的时间戳对齐:对齐的前提是全链路只有一套时间基准。建议统一到媒体 timeline 的毫秒,并注意 ffprobe 报出来的 start_time 可能不为 0(TS 流里很常见),要先减掉起始偏移再落库;可变帧率视频更不能拿「帧号 ÷ fps」推时间,必须用真实 PTS——抽帧时加 -copyts 配合 -frame_pts 1 命名,或者用 showinfo 打印 pts_time,把「文件名 → 时间戳」写成 sidecar 一起落盘。第二件事是把信息区间化而不是点化:OCR 出来的字幕在时间上是一段区间(出现到消失),ASR 出来的也应该是区间(句级,需要词级就开 word_timestamps),两者统一表达成 [start, end, text, source, confidence],再做区间交叠 join,而不是拿单点时间戳比距离。第三件事是窗口聚合:按固定窗口把同一窗口内的关键帧、OCR 文本、ASR 文本聚成一个 multimodal chunk 再送模型,chunk 内保留各自的时间戳;窗口太小会把一句话拦腰截断,太大则几个事件混在一起、摘要开始串味,实践上 1 秒是齐平各模态的栅格粒度、515 秒是语义窗的常用起点。原帖遇到的「音频 16kHz、视频 30fps 直接拼维度对不上」,本质也是采样率不同,正确做法是各自先抽成定长特征再按统一时间栅格对齐(视觉降到 1fps、音频按 1 秒 hop 取特征),而不是硬拼维度。最后是漂移校正:跨模态偏差主要来自 VAD 把首字吞掉或句尾拖长、以及字幕相对语音天然提前 100300ms,可以借场景切换、静音段这类共同锚点做一次互相关校正;上线前至少人工抽 30 条视频量一遍时间偏差,把「偏差大于 0.5 秒的比例」做成常驻指标,比事后靠肉眼看融合结果错位靠谱得多。
沙箱隔离与会话恢复:1000 个沙箱同时跑,第一件事是承认「安全等级不同、隔离手段就该不同」。Docker + seccomp 只是同一内核上的命名空间隔离,逃逸面依旧存在;跑模型生成的不可信代码时,gVisor(用户态内核、拦截 syscall)或 Firecracker 这类 microVM(独立内核、毫秒级冷启动)要安全一个档次,代价是 syscall 密集任务变慢、冷启动更贵。工程解法是分池:高风险任务进 microVM/gVisor 池,可信模板或纯计算任务进 runc 池,池子预创建、任务借还,避免每个任务都付一次冷启动。限额必须逐项落实:CPU quota、内存上限(要让它超限被 OOM-kill,而不是拖垮整台宿主机)、PID 数量、磁盘 IO、临时盘大小;网络默认全拒,只放白名单出口,这既是安全要求也是防数据外泄的必要条件。回收侧要有硬超时(单沙箱 30 秒级)+ 空闲回收 + 定期对账「分配表 vs 实际进程」,防止沙箱泄漏;调度上用反亲和性把沙箱打散到不同节点,单节点水位控制在七成左右留突发余量,否则一台机器上几百个沙箱会互相抢内存带宽。会话恢复的正确姿势是「状态不在沙箱内存里」:每完成一个子任务就写 checkpoint(状态 + 版本号 + 幂等键)到 Redis,写入要原子(单条 Lua 或 MULTI/EXEC),别写一半;容器重启后从最近 checkpoint 续跑,DAG 里已完成的子任务直接跳过。版本号不匹配就丢弃旧状态重放,避免新代码读到旧结构。要强调的是重放必须幂等——工具调用的结果要有去重缓存(相同 tool + 参数在时间窗内直接返回缓存结果),否则恢复之后就是重复下单、重复写库这类线上事故。
线程池参数与 OOM / GC 排查:线程池参数不该背公式,应该由「目标 QPS × 单任务耗时」倒推并发度,再被下游配额卡一道——下游 ASR/LLM 有 QPS 上限时,池子开太大只会把下游打挂。IO 密集 2N、CPU 密集 N+1 只是压测的起点,最终参数看活跃线程数、队列长度、拒绝次数和任务 P99 来定。队列必须有界,无界队列的后果是任务堆到 OOM 而不是快速失败;CallerRunsPolicy 在入口线程是业务线程时能形成天然背压,但要注意它会让调用线程同步跑任务,调用方若是 RPC/IO 线程反而会拖慢上游,而可丢弃的旁路任务更适合 DiscardPolicy + 拒绝次数进监控。线程要命名、线程池要有指标暴露,流量波峰波谷明显的场景再考虑 allowCoreThreadTimeOut 和动态调参。OOM 定位的第一件事是看报错类型:Java heap space、Metaspace、Direct buffer memory、unable to create native thread 指向四个完全不同的方向。堆内先 -XX:+HeapDumpOnOutOfMemoryError 兜住现场,再 jmap -dump:live,format=b,file=/tmp/heap.hprof <pid> 用 MAT 的 dominator tree 找「谁在持有」;堆外看 -XX:MaxDirectMemorySize,必要时开 NMT 用 jcmd <pid> VM.native_memory summary;元空间则去查动态类生成(CGLIB、反射、脚本引擎)。ThreadLocal 泄漏的机理值得讲透:线程池线程长期存活,ThreadLocalMap 的 key 是弱引用、value 是强引用,线程不死 value 就跟着活,修复就是在 try/finally 里 remove(),并在 Filter 或装饰器里统一收口。频繁 GC 也要先分类:年轻代 GC 频繁多半是分配速率太高或 Eden 太小,方向是减少热点路径上的临时对象和字符串拼接;Full GC 频繁说明老年代被长期对象或大对象占满,先找泄漏,用 -XX:+HeapDumpBeforeFullGC 抓前后对比,换 G1/ZGC 永远放在最后一步——有泄漏时换收集器只是让 OOM 晚来一会儿。