2026年-腾讯游戏-第十一届游戏安全技术竞赛-比赛笔记
进了决赛,可惜折戟决赛,未能获得名次。
由于手头GPU不足,故采用了一些方法去减轻GPU的沉重负担,比如GBDT蒸馏LLM,比如小模型打草稿大模型来润色的思路。
也不知道决赛会不会因为使用了树模型和非完整end to end生成式训练而被“一票否决”。
初赛
任务
任务定义:构建一个生成式模型,输入20秒游戏片段的所有玩家信息,预测接下来 5s 的:
(1)主玩家决策(选择交战/避战)
(2)行为变化(离散行为:救援队友、避战后撤、开火、丢雷、靠近远离等等)
解法
我的解决方案分为4层:
- 日志解析与摘要压缩
把原始 20 秒日志整理成尽量保留关键信息的摘要文本。 - 善于利用结构化信息的teacher(GBDT,在代码中我们使用的是Catboost)
先训练一个传统分类器,输出 6 类动作概率分布。 - 生成式 student(Qwen3-8B QLoRA)
用生成式模型学习日志到行为的映射。 - 蒸馏
先分别训练teacher模型和student模型。再让 student 在 6 类候选分数上拟合 teacher 分布进行蒸馏训练。
我们会固定 6 个候选答案,并计算它们的预测概率,再选最高者。(类似于qwen3guard-stream的处理)。
数据处理与摘要策略
训练集中的原始日志包含“决策”行。代码会先找到第一条带决策含义的行,并把:
- 决策行之前的日志作为输入
- 决策对应的动作作为标签
测试集没有决策行,因此直接把完整 20 秒日志读入,再做摘要:
摘要构造不是简单截断,而是有结构地保留关键信息。summarize_lines 的主要策略是:
- 保留开局玩家信息
- 保留非基础事件
- 对“玩家基础信息”按关键时间点抽样
- 默认关键时刻:
0, 5, 10, 15, 18, 19, 20
- 默认关键时刻:
- 如果事件过多,对中间做省略
- 如果整体字符数过长,再做头尾保留的中间裁剪
最终摘要会尽量保住:
- 开局阵容
- 关键事件
- 关键时间点的状态变化
- 后段临近决策时刻的局势
这种处理方式的目的,是让长日志尽量转成对行为预测有判别力的文本。
生成式模型的基本预测方式
我们一开始是打算采取让模型直接生成预测。但是这样出现两个问题:
- 输出格式不稳定。这样会导致我们对答案的正确读取的难度也会加深。
- 生成出不在 6 类里的动作表达。
所以当前方法把输出空间收缩成 6 个固定候选JSON。
每个动作都对应一个固定 JSON:
1 | |
对每条输入摘要,模型不会只生成一个答案,而是对 6 个候选答案分别计算:
- 条件对数概率
- 再按目标 token 数做平均
最后取平均分最高的动作作为预测结果。
这一步的好处:
- 输出严格受控
- 任务从“自由生成”变成“受控的生成式分类生成”
- 能直接得到6类分数,便于后续蒸馏、校准和分析
蒸馏方案的详细介绍
为了快速训练和引入善于利用结构化数据的模型,我们选用GBDT作为我们的teacher模型。
当前蒸馏使用的是:
- teacher:GBDT 输出的 6 类动作概率分布
- student:Qwen3-8B 量化基座 + LoRA adapter
二者之间不是通过中间文本传信息,而是通过6类分数分布对齐。
GBDT模型使用了以下特征进行初始训练。
(1). 文本特征
对 summary 做:
- HashingVectorizer
- analyzer=’char’
- ngram_range=(2,4)
- n_features=16000
- alternate_sign=False
- norm=None
- 再接 TfidfTransformer(sublinear_tf=True)
- 再接 TruncatedSVD
- n_components=160
- n_iter=8
(2). 数值统计特征
也是从 summary 里直接算的,不用原始结构化状态表。
具体包括:
- summary_char_len
- summary_line_count
10 个短语计数:
- 玩家造成伤害
- 技能生效
- 换弹
- 标点
- 濒死掉血(第1次倒地)
- 濒死掉血(第2次倒地)
- 濒死掉血抑制
- 快速救援
- 救援中的标记
- 救援后的标记
3 个比例特征:
- ratio::combat
- (玩家造成伤害 + 技能生效 + 换弹) / line_count
- ratio::avoid
- (3个倒地相关 + 3个救援相关) / line_count
- ratio::marker
- 标点 / line_count
所以数值特征总数是:
- 2 + 10 + 3 = 15 维
最终给 GBDT 的输入是160 维文本 SVD特征 + 15 维数值特征 = 175 维
另外,模型结构不是单个6分类,而是两阶段:
- intent_model:交战 / 避战
- combat_model:交战内4分类
- avoid_model:避战内2分类
最终 6 类分数是: log P(intent) + log P(action | intent)
为了模型具有很好的生成能力和判别能力,我们并不直接进行蒸馏。
而是先使用QLORA从头训练一个大模型作为初始LoRA权重,再继续训练LoRA参数做蒸馏。
对每个样本,student会输出6个候选动作分数。teacher则给出6类概率分布。
训练目标分成两部分:
监督分类损失
CE(student_scores, gold_action)- 让 student 对真实标签有区分能力
teacher 蒸馏损失
KL(student_dist, teacher_probs_6)- 让 student 的分布尽量接近 teacher。
总损失定义为:
1 | |
为什么不直接把GBDT的输出拼接到提示词上
之前也做过“把 GBDT top3 候选和意图写进 prompt”的方案,比纯大模型要好,但是提升较小。原因可能是:
- 模型仍然可能忽略提示,继续沿用旧边界
- 文字先验和最终评测目标之间还有一层间接映射
当前蒸馏是直接在6类候选分数上对齐,更直接,也更符合最终预测机制。
不过由于比赛匆忙,并没有来得及计算的GBDT蒸馏大模型的评估分数。
决赛
任务
任务定义:构根据前 20 秒游戏日志,续写主玩家接下来 5 秒的行为文本。
最终不是输出分类标签,而是输出自然语言续写。因此整个方案不能只做动作分类,还必须解决两个问题:
- 结构正确:主决策、核心动作、后 5 秒阶段行为不能跑偏。
- 文本可读:最终续写要尽量自然,且接近标注文本的表达。
同时,面临任务的加重,初赛所使用的直接微调qwen会产生训练成本过重的问题。
围绕比赛目标和实际手头上的资源,最终形成了三层结构:
GBDT:提供稳定的结构先验。mT5-large / mT5-xl:把结构先验写成三行结构化续写。Qwen-editor:在不重新理解全量长日志的前提下,对mT5草稿做双草稿融合与润色。
本来还花了一天来训练了一个byT5,可惜没来得及。
解法
整体方案演化
最初
最早的决赛赛主线是想类似初赛的解题方案,直接用 Qwen3-8B 做结构化续写,输出:
1 | |
这条线在结果上是可用的,但有两个硬问题:
decoder-only模型的输入和输出共用长度预算。- 决赛 prompt 太长,
response-only训练下会导致样本被删。
后续对缩短版 Qwen prompt 做了全训练集长度审计,结果是:
- 训练集总数:
88516 2048下可保留样本:44292kept_ratio = 0.5004
也就是说,即便已经把 prompt 改成 short 版,2048 下仍然会损失约一半训练样本。
这说明:初赛所使用的Qwen适合做短编辑任务,不适合继续扛长上下文主生成任务。
对应的基础提示词形态是:
系统提示词:
1 | |
用户提示词模板:
1 | |
转向 GBDT + mT5
为了把“结构预测”和“自然语言生成”拆开,方案转成两阶段:
GBDT先做结构预测mT5再做结构化生成
这样做的核心收益是:
GBDT提供动作与阶段子句先验,结构稳定mT5是encoder-decoder,输入输出长度分开,不会像Qwen一样因为 prompt 过长把答案挤掉
在 GBDT + mT5 基础上,继续观察到:
GBDT文本很像模板,ROUGE 高,但不够自然mT5自然度更高,但仍会出现阶段偏差、表达僵硬或者 draft 间不一致
因此最终把 Qwen 改为 编辑器,而不是主生成器:
- 输入:
V1 先验 + mT5-large 草稿 + mT5-xl 草稿 + 主玩家近 2 秒焦点 - 输出:一行最终续写
这样做有两个决定性好处:
- prompt 很短,
2048够用 Qwen被用在它更擅长的部分:中文融合、润色、句子组织
GBDT 结构先验层
GBDT 不直接生成最终文本,而是预测:
pred_actionpred_intentpred_clause1pred_clause2pred_clause3
其中:
pred_action为 6 分类:- 开火 / 开镜 / 放技能 / 丢雷 / 搜物资 / 救援队友
pred_intent由动作映射:- 开火 / 开镜 / 放技能 / 丢雷 -> 交战
- 搜物资 / 救援队友 -> 避战
最终模板化输出为:
1 | |
训练样本来源于决赛训练集,按决策行切分:
summary:前 20 秒摘要focus_text:主玩家近 2 秒相关事件和状态target_text:根据未来 5 秒规则生成的三行结构化目标
同时还会从 target_text 中拆出三段子句标签:
clause1_labelclause2_labelclause3_label
GBDT 用的是文本特征 + 数值特征混合方案。
文本特征:
summary:- char 2-4 gram
HashingVectorizer + TF-IDF + SVD(160)
focus_text:- char 2-4 gram
HashingVectorizer + TF-IDF + SVD(64)
数值特征包括:
summary_char_lenfocus_char_lensummary_line_countfocus_line_count- 事件计数:
- 玩家造成伤害
- 技能生效
- 换弹
- 标点
- 倒地 / 救援相关事件
focus_text中主玩家动作/伤害/技能计数- scope 开镜/关镜计数
- 主玩家状态位移量
- 最后一帧是否处于开镜状态
框架:CatBoostClassifier
固定配置:
task_type = GPUdevices = 0:1(在CUDA_VISIBLE_DEVICES=1,2下对应双卡)iterations = 1200learning_rate = 0.05depth = 8l2_leaf_reg = 10early_stopping_rounds = 100
action_model 先训练;随后把 action probs 拼到特征后面,再分别训练:
clause1_label_modelclause2_label_modelclause3_label_model
结果
训练耗时:
runtime_seconds = 1531.64- 约
25.5分钟
验证结果:
| Split | action | intent | rouge_l |
|---|---|---|---|
val_natural |
0.8650 |
0.9755 |
0.8001 |
val_balanced |
0.7733 |
0.9783 |
0.7758 |
ratio_val |
0.8583 |
0.9700 |
0.8051 |
结论:
- V1 的动作准确率不一定最好
- 但
ROUGE-L最强 - 说明结构模板非常稳定,是后续所有生成模型的可靠先验
V3:mT5-large / mT5-xl 结构化生成层
V3 不是直接看原始长日志,而是看:
V1主决策先验V1核心动作 top3V1阶段1/2/3先验focus_text- 压缩后的
summary
输入模板如下:
1 | |
目标文本固定是三行结构化文本。
训练配置:
max_source_length = 1024max_target_length = 128num_train_epochs = 1per_device_train_batch_size = 2per_device_eval_batch_size = 2gradient_accumulation_steps = 8learning_rate = 1e-4weight_decay = 0.01bf16 = Truegradient_checkpointing = True
LoRA:
r = 16alpha = 32dropout = 0.05target_modules = ["q", "v"]
训练结果:
train_runtime = 15518.79s- 约
4.31h train_loss = 15.2807
相比 mT5-large,mT5-xl 参数更多,配置更保守:
per_device_train_batch_size = 1gradient_accumulation_steps = 16- 其它配置尽量与
large保持一致
从已保存 checkpoint-2500 看,训练稳定进行到:
global_step = 2500epoch ≈ 0.904
Qwen-Editor:双草稿融合层
系统提示词:
1 | |
用户提示词模板:
1 | |
对 dual-draft editor prompt 做过长度检查。结论是:
2048基本够用- 少量样本会超预算
因此实际推理时采用:
max_seq_length = 2048max_new_tokens = 128- 只对
focus_text做裁剪 - 不裁:
V1 priorsmT5-large/xl草稿
最终 non-ICL dual-draft 结果里:
prompt_over_budget_before_fit_count = 3prompt_over_budget_after_fit_count = 0
做过的失败尝试与挫折
失败一:把 Qwen 当主生成器
问题:
decoder-onlyresponse-only- 决赛 prompt 太长
结果:
2048下训练集保留率只有50.04%
结论:
- 不再让 Qwen 直接扛“长上下文理解 + 主生成”
- 转成 editor
失败二:Qwen-editor 做 1-shot retrieval ICL
实现方式:
- 用
val_balanced做支持样本 - 按
V1 action / clause / draft 相似度检索 1 条示例 - 在 prompt 里加入 one-shot ICL
结果:
- non-ICL dual-draft:
0.8045 - 1-shot ICL dual-draft:
0.7792
也就是说:
- ICL 仍优于
mt5-large/xl基线 - 但明显差于 non-ICL
结论:
- 当前这题上,Qwen-editor 更适合短而硬的约束 prompt
- 不适合在
2048下继续加示例
失败三:尝试训练版 Qwen-editor
训练版 Qwen-editor 的思路是:
- 先对
train全量生成mT5-large/xlteacher 草稿 - 再训练 Qwen-editor 去学习融合这两条草稿
但这条线在工程上遇到了明显瓶颈:
train = 88516- teacher drafts 需要逐条生成
mT5-large和mT5-xl都要跑
估算进入 Qwen 正式训练前,teacher 生成就要数十小时到数天。
因此这条线最终没有跑成正式主线,只完成了:
- teacher draft smoke
- editor 数据集 smoke
- Qwen-editor 训练 smoke
结论:
- 在时间有限的条件下,不值得为 train 全量草稿付出这种代价
- inference-only dual-draft editor 性价比更高
总结
| 模型 | rouge_l_on_xuxie |
|---|---|
mT5-large |
0.7648 |
mT5-xl |
0.7647 |
Qwen-editor(non-ICL, dual-draft) |
0.8045 |
Qwen-editor(ICL, dual-draft) |
0.7792 |
结论:
- 当前最优不是单个
mT5 - 而是:
V1 GBDT约束 +mT5-large/xl双草稿 + non-ICL Qwen-editor`
graph TD
classDef largeModel fill:#e8f0fe,stroke:#448aff,stroke-width:2px,rx:10,ry:10,font-family:Arial, sans-serif;
classDef xlModel fill:#fff8e1,stroke:#f57c00,stroke-width:2px,rx:10,ry:10,font-family:Arial, sans-serif;
classDef rewardModel fill:#e0d6fa,stroke:#7c4dff,stroke-width:2px,rx:10,ry:10,font-family:Arial, sans-serif;
classDef ensembleModel fill:#e0f2f1,stroke:#009688,stroke-width:2px,rx:10,ry:10,font-family:Arial, sans-serif;
Large["<div style='color:black; text-align:center;'>1x ByT5-Large<br/>0.7648</div>"]:::largeModel
XL["<div style='color:black; text-align:center;'>1x ByT5-XL<br/>0.7647</div>"]:::xlModel
Reward["<div style='color:black; text-align:center;'><b>Pairwise Reward Model</b><br/><span style='color:grey'>Qwen3-8B</span>"]:::rewardModel
Ensemble["<div style='color:#009688; text-align:center;'><b>ENSEMBLE</b><br/>0.8045</div>"]:::ensembleModel
subgraph Top
Large ~~~ XL
end
style Top fill:none,stroke:none;
Large --> Reward
XL --> Reward
Reward --> Ensemble
linkStyle default stroke:#999,stroke-width:2px;