本次做了一个简单的实验,使用GPT辅助生产的地图控制SFT数据(约2.4w条,基于opengis harness的tool构造),对qwen3.5-2b进行lora微调,部署在一张4090上,让opengis接入口执行地图控制与要素样式修改,初步尝试端侧模型的可行性。
SFT 与 LoRA 微调
SFT 监督微调
SFT(Supervised Fine-Tuning,监督微调)指用带标注的指令-回复数据继续训练基座模型,把学习目标从预测语料中的下一个 token,换成给定指令生成示范回复。在预训练、SFT、偏好优化构成的三阶段对齐流程中,它位于基座模型和偏好优化之间,承担分布迁移的作用。这一阶段不要求模型学新知识,它的任务是把模型已经具备的语言能力收敛到具体任务的输出分布上。
我们再详细一点,例如,我们准备这样一条训练数据:
1 | User: |
在训练过程中,我们希望,收入User信号给LLM,预测结果与标准答案比较产出Cross Entropy Loss,通过 Backpropagation 更新参数。因此,SFT 本质上还是一个标准的监督学习问题。
为了更加深入的了解,我们必须知道,自回归语言模型对一段回复的建模方式是逐 token 的条件概率连乘:
其中 是 prompt, 是示范回复。SFT 让这个连乘在训练数据上尽可能大,等价于在每个回复 token 上最小化负对数似然:
这个损失只覆盖回复部分的 token,prompt 是条件而不是待拟合的目标,如果它参与损失,模型容量会分给复现指令模板,而不是学习作答行为。工程实现上把 prompt 位置的标签置为忽略索引(PyTorch CrossEntropyLoss 的 ignore_index 参数,取 -100),梯度只在模型生成的 token 上回传。而多轮对话按角色逐轮标注掩码,只有 assistant 轮次计入损失。
从宏观上来看,SFT可以改变模型的行为模式、输出格式、任务能力、领域适应。
LoRA
LoRA 全称 Low-Rank Adaptation,中文一般叫“低秩适配”。它属于 PEFT(Parameter-Efficient Fine-Tuning,参数高效微调)的一种。核心思想是不去更新大模型原本的全部参数,而是在部分线性层旁边增加一组很小的低秩矩阵,只训练这些新增参数。
对于神经网络中的线性层,其前向传播可以表示为:
全参数微调(Full Fine-Tuning)直接更新完整权重矩阵 :
LoRA 则假设模型适应下游任务所需的权重变化 具有低秩(Low-Rank)特性,因此可以使用两个较小的矩阵进行分解:
其中:
因此,加入 LoRA 后的前向传播变为:
经典 LoRA 通常还会引入缩放系数 :
训练过程中,原始权重 保持冻结,只更新低秩矩阵 和 ,从而大幅减少需要训练的参数量。其成立的基本条件基于一个核心假设:大模型在适配一个新任务时,真正需要发生的参数变化并不需要占满整个高维参数空间,而是集中在一个相对低维的子空间中。 也就是说,虽然 可能是一个巨大的 矩阵,但新任务真正需要的变化方向可能只有几十维。举个具体数字。假设一个线性层 完整参数量为: ,约 1678 万,如果 LoRA 的 ,那么参数量是,只有大约 13 万。
因此,我们可以简单的讲 LoRA 理解成先把信息投影到一个很小的低维空间,然后再映射回来,当然,原本的 原始路径是完全保留的,只是同时增加了一条支路。训练过程中梯度可以经过整个模型计算,但优化器真正更新的只有 LoRA 参数。
LoRA 最常加在 Transformer 的 Linear 层上。以 Attention 为例,、、 以及输出投影都包含线性变换,因此常见的 LoRA 注入位置包括 q_proj、k_proj、v_proj 和 o_proj;对于更充分的参数适配,也可以进一步作用于 MLP 中的 gate_proj、up_proj 和 down_proj。
LoRA 中最重要的几个超参数包括:
r:低秩空间的维度,决定 LoRA 的参数量和学习容量。 越大,模型可学习的变化越丰富,但显存和计算开销也随之增加,常见取值为 8、16、32、64。lora_alpha:LoRA 分支的缩放系数。经典 LoRA 中实际增量为 ,因此 决定 LoRA 分支对原模型的影响尺度。lora_dropout:LoRA 分支上的 Dropout,用于降低过拟合风险。target_modules:指定哪些 Linear 层注入 LoRA,注入范围越大,通常意味着更强的适应能力和更高的训练成本。
例如,一个常见配置可以写成:
1 | LoraConfig( |
LoRA 能够显著降低训练成本的原因不仅在于可训练参数更少。全参数微调需要为大量模型参数维护梯度和 Optimizer State,而 LoRA 冻结 Base Model,只需要为少量低秩参数维护这些训练状态,因此可以显著降低训练显存占用。不过,Base Model 本身仍然需要加载到显存中;如果进一步将基础模型以 4-bit 等低精度方式加载,再结合 LoRA 训练,就形成了常见的 QLoRA。
需要注意的是,SFT 和 LoRA 并不是同一级别的概念:SFT(Supervised Fine-Tuning)描述的是模型通过监督数据学习目标输出的训练范式,而 LoRA 描述的是训练过程中如何高效更新参数。因此工程中常见的 SFT + LoRA,本质上就是使用 SFT 的监督学习目标计算 Loss 和反向传播,但只更新 LoRA 参数。
训练完成后,LoRA 通常只需要保存一个体积较小的 Adapter:
1 | Base Model + LoRA Adapter → Fine-tuned Model |
同一个 Base Model 可以加载不同 Adapter 来适配不同任务,也可以将 LoRA 参数直接 Merge 回原模型:
从而得到一个无需额外挂载 LoRA Adapter 的完整模型。
因此,LoRA 的核心可以概括为:通过低秩矩阵近似下游任务所需的权重增量,在保留基础模型原始参数的同时,仅训练少量新增参数,以较低的显存、计算和存储成本完成模型适配。
我们的 SFT 目标
为了测试基于 OpenGIS harness 提供 tool 以及 operation(原子化的GIS操作),能否使用端侧部署的小模型胜任简单的地图操作与空间分析能力,我计划使用qwen的小模型进行 LoRA 微调并部署测试。由于 GIS 分析相对复杂,涉及到的工作体量完全可以作为一篇论文了,我们本文与现阶段目标是在现有 Qwen3.5-2B 基座上,进行地图操作工具调用的监督微调,使模型能把用户指令和地图状态转换为 OpenGIS 可执行的工具名及参数。
本次 SFT 操作数据与微调对象覆盖地图控制、图层管理、样式、查询、过滤等 18 种工具。下表展示了我们的各项参数,以及最终的微调结果,读者可以做一个简单的预览:
| 核心项目 | 当前记录 |
|---|---|
| 基座 | Qwen3.5-2B,本地权重 |
| 最终数据 | 27,123 条,18 种工具 |
| 数据划分 | train 23,868 / eval 1,627 / test 1,628 |
| 训练方式 | BF16 LoRA,双 RTX 4090,DDP |
| 配置轮数 / 总 step | 2 epochs / 1,492 |
| 停止进度 | 400 step,0.536283 epoch,26.81% |
| 当前训练 loss | 0.04337690 |
| 当前验证 loss | 0.04338664 |
| 当前验证 token accuracy | 98.6073% |
为什么从地图操作切入?
OpenGIS 的端侧模型建设可以分为地图交互和空间分析两个方向。地图交互具有较清晰的输入输出边界:用户给出要求,模型结合当前图层与地图状态选择工具,工具执行后产生可观察的界面变化。颜色、标注、图层顺序和视角等结果比较容易检查,因此适合作为训练和部署链路的首期验证对象。而空间分析我们刚才也说了,往往涉及数据质量、坐标参考系、单位、几何有效性和操作顺序,验证成本更高,工作量大,适宜在基础链路稳定后逐步加入。
基座模型与数据构造
基座模型
本项目采用 Qwen3.5 系列模型作为基座模型,其整体架构为 Qwen3_5ForConditionalGeneration,属于面向条件生成任务设计的多模态大模型。语言侧包含 24 层 Transformer,隐藏维度为 2048,前馈网络中间维度为 6144,并采用 8 个注意力头与 2 个 Key-Value Head,在控制模型规模的同时兼顾推理效率;词表规模达到 248,320,最大位置编码长度为 262,144,具备较强的长上下文处理能力。视觉侧同样采用 24 层编码结构,隐藏维度为 1024,Patch Size 为 16,可将图像划分为视觉 Token 并与文本表示进行联合建模。整体上,该模型兼具文本生成、长上下文理解和视觉信息编码能力,适合作为后续领域 SFT 与 LoRA 微调的基础模型。
训练硬件为两张 RTX 4090,各 24,564 MiB。
SFT 数据构建过程
数据流程可以简单的归纳为:
从真实工具生成 Operation Cards
构建脚本启动 OpenGIS ToolRegistry,自动发现已经注册的工具,并通过 tool_pack_for_registered() 只选取 map 和 style 两类工具。对于每个工具,脚本提取以下信息:
- 工具名称、所属 Tool Pack、功能类别和显示名称;
- 工具描述、返回值描述、标签和示例;
- 参数名称、类型、说明、默认值与必填状态;
- 可直接用于模型 Tool Calling 的 OpenAI Function Schema。
最终包含以下18种:
| 能力类型 | 工具 |
|---|---|
| 图层管理 | add_layer、remove_layer、list_layers、get_layer |
| 地图视图 | fly_to、zoom_to_layer、get_map_state、set_basemap_visibility |
| 查询与选择 | query_features、highlight_features |
| 普通样式 | update_layer_style、set_layer_visual_variables |
| 专题制图 | set_graduated_style、set_categorized_style |
| 图层表达 | set_layer_filter、set_layer_label、set_layer_order、update_legend_spec |
源轨迹构建
数据结构
源数据使用轨迹结构保存。每条记录包含稳定标识、意图、业务权重、标签、地图状态、用户表达和目标轨迹。地图状态包括工作空间、视图中心、缩放级别、底图状态、图层列表、几何类型、字段和活动图层。工具参数中的 layer_id 和字段名称可以因此与环境状态建立对应关系。
工具调用轨迹包含 OpenAI 风格的函数名称及 JSON arguments,并可以附带模拟的成功结果。多工具样本可以在同一条轨迹中包含多个调用。无工具样本则保存 assistant 文本,用于训练信息不足时的澄清行为或普通问答行为。
下面是源数据中真实存在的澄清样本:
1 | { |
这类样本的意义在于:相同的“把它改成红色”在有活动图层时可以调用样式工具,在没有任何图层时应该先澄清。模型需要学习地图状态会改变正确行为,而不是只把关键词“红色”固定映射到某个函数。
四类源数据
源轨迹由四部分构成:
- 基础 Seed:为 18 种工具提供核心调用、常见表达和基础参数示例。
- 参数组合:系统组合颜色、透明度、点大小、字段、分类方法、分类数、色带、过滤条件、标注字段和图层顺序等变量。
- 多工具轨迹:让一次用户请求产生多个存在顺序关系的地图或样式调用。
- 澄清与负样本:覆盖缺少图层、缺少字段、无需操作地图等场景。
参数化生成比单纯改写一句话更重要,例如,分层设色会组合数值字段、quantile、equal-interval、jenks 等分类方法、3/5/7 个分类等级以及不同色带;过滤和查询则组合字段、比较操作符和具体值。它们扩展了模型需要判断的参数空间。
每条源轨迹带有 weight 元数据,用于表达业务优先级。样式、分层设色、分类设色、过滤和多步操作通常具有较高权重
类别平衡
初始展开阶段共生成 23,429 条记录。由于参数组合空间不同,分层设色和普通样式数量远高于其他工具。如果直接使用全部记录,模型可能过度偏向高频工具,因此构建脚本对各类工具设置数量上限。
默认单工具上限为 2,400 条,部分高频工具使用单独上限:
| 工具 | 上限 |
|---|---|
set_graduated_style |
2,400 |
update_layer_style |
2,000 |
set_categorized_style |
1,600 |
set_layer_filter |
1,400 |
set_layer_visual_variables |
1,400 |
assistant 澄清和负样本不受该工具上限限制。类别平衡后保留 9,041 条源记录,其中 8,819 条包含工具调用,222 条为 assistant 文本。平衡降低了最大类别的绝对规模,但没有将 18 类工具变成均匀分布;高频专题制图和样式操作仍然占据主要部分,这是当前数据的业务倾向。
自然语言扩写
在 9,041 条源轨迹基础上,为每条记录生成 2 条新的用户表达,共新增 18,082 条记录。扩写只替换用户请求,地图状态、目标工具、工具参数、业务权重和轨迹保持不变。扩写根据工具语义处理常见表达,例如把“分层设色”替换为“分级渲染”、把“高亮”替换为“突出显示”,或将结构化参数重新表达为自然语言。多工具轨迹会将多个调用描述组合成有顺序的请求;无工具记录会生成新的澄清或无需工具表达。
数据结果
总体规模如下:
| 指标 | 结果 |
|---|---|
| 最终训练记录 | 27,123 |
| 含工具调用记录 | 26,457(97.54%) |
| 澄清或无工具记录 | 666(2.46%) |
| 覆盖工具 | 18 |
| 工具调用总次数 | 27,321 |
| 结构校验错误 | 0 |
| 源记录 | 9,041 |
| 新增改写 | 18,082 |
工具调用总次数高于含工具调用记录数,是因为部分多步样本在一条记录中包含多个工具调用。工具分布如下:
| 工具 | 调用次数 | 调用占比 |
|---|---|---|
set_graduated_style |
7,338 | 26.86% |
update_layer_style |
6,138 | 22.47% |
set_categorized_style |
3,039 | 11.12% |
set_layer_visual_variables |
2,016 | 7.38% |
set_layer_filter |
1,932 | 7.07% |
query_features |
1,440 | 5.27% |
set_layer_order |
1,404 | 5.14% |
set_layer_label |
1,197 | 4.38% |
fly_to |
399 | 1.46% |
highlight_features |
366 | 1.34% |
update_legend_spec |
354 | 1.30% |
zoom_to_layer |
351 | 1.28% |
set_basemap_visibility |
336 | 1.23% |
get_layer |
210 | 0.77% |
remove_layer |
210 | 0.77% |
add_layer |
204 | 0.75% |
get_map_state |
195 | 0.71% |
list_layers |
192 | 0.70% |
图1:SFT 数据的工具调用分布
并按 88%/6%/6% 划分Train、Eval与Test。
本阶段建立了一条从 OpenGIS 真实工具定义出发,经过轨迹构造、参数组合、类别平衡、自然语言扩写、结构校验和格式转换,最终形成 Qwen SFT 数据的完整工程管线。最终数据包含 27,123 条记录、18 种地图工具和 27,321 次工具调用,其中 26,457 条为工具调用记录,666 条为澄清或无需工具记录,并完成 Train、Eval、Test 三部分划分。
训练配置与流程
训练配置
本次训练采用双 GPU 数据并行(Data Parallelism)策略,每张 GPU 均维护一份完整的模型副本,并分别处理不同的 micro-batch;反向传播后,各 GPU 通过梯度同步(通常采用 All-Reduce)聚合梯度,从而保证模型参数保持一致。需要注意的是,数据并行的主要作用是提高训练吞吐量并扩大有效 Batch Size,而不是将两张 24 GB GPU 的显存合并为一块连续的 48 GB 显存空间,因此单个模型副本及其训练状态仍需满足单卡显存约束。
在数据并行与梯度累积共同作用下,全局有效 Batch Size(Global Effective Batch Size)可表示为:
本次训练配置为单卡 Batch Size = 1、梯度累积步数 = 16、GPU 数量 = 2,因此:
对于包含 23,868 条样本的数据集,每个 Epoch 理论上对应约 个参数更新步(Optimizer Steps)。训练 2 个 Epoch 后,总训练计划约为 1,492 个 Optimizer Steps。因此,训练进度条中的 step 表示完成梯度累积后的全局优化器更新次数,而非单张 GPU 处理的 micro-batch 数量;由于最后一个 Batch 可能不足完整 Batch Size,实际步数及尾批次处理方式应以 Trainer 最终记录的训练状态为准。
训练采用 BF16 精度加载基座模型,并使用 PyTorch SDPA 完成注意力计算。为降低训练过程中 Activation 带来的显存占用,启用 Gradient Checkpointing 并关闭 use_cache。当前实验未启用 4-bit 量化,因此采用标准 LoRA 而非 QLoRA;配置中保留 NF4、BF16 Compute Dtype 与 Double Quantization 参数,以便后续扩展至 QLoRA 训练。
LoRA 设置 Rank 、、Dropout 为 0.05,并将 Adapter 注入 Attention 的 q_proj、k_proj、v_proj、o_proj 以及 MLP 的 gate_proj、up_proj、down_proj,从而在冻结基座模型参数的基础上,对 Attention 与前馈网络进行任务适配。
训练共进行 2 个 Epoch,学习率设置为 ,采用 AdamW 优化器 + Cosine 学习率调度,Warmup Ratio 为 0.03。单卡 Batch Size 为 1,梯度累积步数为 16,最大序列长度设置为 4096 Token;训练采用 BF16,并启用 TF32 加速部分矩阵运算。模型每 100 个 Optimizer Step 进行一次评估与 Checkpoint 保存,最多保留最近 3 个 Checkpoint。
数据集按照 88% / 6% / 6% 划分为训练集、验证集和测试集,并在划分前进行随机打乱。训练读取统一处理后的 text 字段,同时保留原始 Messages。实验过程中额外记录数据集 Profile、训练样例、运行环境及 Git 状态,以保证训练过程具有良好的可追踪性与可复现性。
训练过程中,原始 messages 首先通过 Chat Template 转换为符合基座模型对话协议的文本序列,再经 Tokenizer 编码为 Token 序列。模型采用自回归方式,在已有上下文条件下逐 Token 预测后续内容,并通过 交叉熵损失(Cross-Entropy Loss) 衡量预测分布与目标 Token 之间的差异。对于 Tool Calling 任务,模型不仅需要学习工具协议的结构与格式,还需要正确生成函数名称、参数字段及其具体取值。相比高度重复的结构 Token,函数名称、图层标识和参数值等任务相关内容通常具有更高的信息密度,其错误也更容易直接导致工具调用或任务执行失败,因此不同 Token 的预测错误在实际应用中的影响并不完全等价。
本次 SFT 采用 Teacher Forcing(教师强制)进行训练,即模型预测当前位置 Token 时,输入上下文始终来自真实的目标序列;而在实际自回归推理过程中,后续预测依赖模型此前生成的内容,因此早期错误可能沿生成序列持续传播并产生误差累积(Error Accumulation)。这意味着较低的训练 Loss 只能说明模型在教师强制条件下具有较好的 Token 级预测能力,并不能直接保证自由生成时完整 JSON 的格式正确性、Tool Call 的参数准确性以及多步工具调用链的任务成功率。因此,在训练指标之外,还需要通过独立的推理评测对结构正确率、工具调用准确率和端到端任务成功率进行验证,从而建立训练 Loss 与模型实际 Agent 能力之间的联系。
训练过程数据
本次训练在 checkpoint-400 时已达到基本收敛。
图2:训练过程中的 loss 与学习率曲线
每百步长的详细数据如下:
| step | eval loss | eval token accuracy | eval entropy | runtime 秒 | samples/s |
|---|---|---|---|---|---|
| 100 | 0.05180109 | 98.4887% | 0.05356669 | 59.5623 | 27.316 |
| 200 | 0.04565005 | 98.5510% | 0.04524021 | 59.422 | 27.38 |
| 300 | 0.04443359 | 98.5780% | 0.04536038 | 59.6038 | 27.297 |
| 400 | 0.04338664 | 98.6073% | 0.04436830 | 59.5518 | 27.321 |
从训练过程来看,模型的 Training Loss 整体呈持续下降趋势,并在训练后期逐渐趋于稳定,表明模型已能够较好地拟合当前 SFT 数据分布,参数更新幅度逐步减小,训练过程基本达到收敛状态。同时,Loss 曲线未出现明显的大幅震荡或持续反弹,说明当前学习率、Batch Size 与梯度累积等训练参数整体较为稳定,已经获得较稳定的泛化表现,未表现出显著的过拟合趋势。
实际效果
在实际使用中,通过 VLLM 在一块 4090 上部署了 SFT 微调后的模型,其大致效果如下(位加速,由压缩为较小的gif,画质较差):
图3:端侧 2B 模型在地图控制任务上的运行效果
对比LoRA微调前的2B本地模型,在工具调用的准确率上,微调后的模型在我们的测试任务上从约65%提升至78%,但面对长程任务基本无法完成,这应该触达了2B大小的端侧模型的能力上限。