--- license: apache-2.0 license_link: https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct/blob/main/LICENSE language: - zh pipeline_tag: text-generation library_name: transformers base_model: Qwen/Qwen2.5-1.5B-Instruct tags: - text-generation - qwen2.5 - lora - json - instruction-following - chinese - command-translation --- # darkhorse-code-instruct-1.5b > **把一个微调过的 1.5B 小模型,做成编辑器里的「自然语言 → 私域命令」翻译器。** > 输入一句中文口语,输出**一行严格 JSON**,不是聊天回复。 ``` 输入:关掉倒数第5个文件 输出:{"intent": "command", "cmd": "close -5"} ``` ```json {"intent": "command", "cmd": "del src/P05-es/pdf2es.py"} // 删除类 {"intent": "reject", "reply": "您没有权限!"} // 越权/不合法 {"intent": "chat", "reply": "你还是好好工作吧,房贷还清了吗?车贷还清了吗?"} // 非命令输入 ``` 本模型是 **Qwen2.5-1.5B-Instruct + LoRA(r=16)** 指令微调后**合并**(merged)得到的自包含权重, 不需要 `peft`、不需要 adapter,`transformers` / vLLM 直接加载即可。 --- ## 1. 它解决什么问题 `darkhorse-code` 是一个编辑器项目,它有一套**私域命令语法**(`open project` / `close -3` / `config add -r k=v` …),命令语法固定、可校验,但用户不想背。于是把「用户口语 → 一条标准命令」 这个窄任务交给一个 1.5B 小模型:**小、快、可完全本地跑,且输出必须能被程序解析**。 它不是通用聊天模型,**不会**回答知识问题、不会写代码、不会陪你聊天 —— 遇到非命令输入,它的正确行为是回一句 20 字以内的短回复(或一句固定的「劝退」话术)。 ## 2. 输出契约(三种信封) 模型**只输出一行 JSON**,`intent` 决定信封形状: | intent | 形状 | 含义 | |---|---|---| | `command` | `{"intent":"command","cmd":"<标准命令>"}` | 翻译成功,`cmd` 可直接执行 | | `reject` | `{"intent":"reject","reply":"<拒绝原因>"}` | 命中硬性规则,如路径越权 | | `chat` | `{"intent":"chat","reply":"<≤20字短回复>"}` | 不是私域命令 | 两条固定话术(评测按**逐字**判定): - 绝对路径出现在 `open file` / `new` / `del` / `rename` → `您没有权限!` - 要求把运行目标存成全局配置 → `运行目标不能保存为全局` - 闲聊超过 20 字说不完 → `你还是好好工作吧,房贷还清了吗?车贷还清了吗?` ## 3. 命令语法(模型学到的目标空间) ``` open project <绝对路径> | open file <相对路径> close project|all|other|left|right|<序号> # 序号 0 起,负数从右往左,-1 是最后一个 config add|remove|update|get [-g|-p|-r] = # 未指定层级默认 -p new file <相对路径> | new folder <相对路径> | new py|rs|md|c <名称> del <相对路径> | run <名称>=<命令> | run del <名称> rename <旧相对路径> <新相对路径> project lang <语言> <绝对路径> | project delete <绝对路径> | project migrate project edit "<路径>" "<名称>" <语言> ``` 语言取值:`unknown/mix/java/c/python/rust/web/golang/document/kotlin`(中文表达做了映射:前端→web、Go→golang、文档→document…)。 ## 4. 快速开始(transformers) ⚠️ **两条铁律,违反任一条效果都会明显掉:** 1. **必须使用本模型内置的 system prompt**(下面 `SYSTEM_PROMPT` 原文,逐字), 模型是在这份 prompt 下微调的;换 prompt = 训练/推理不一致。 2. **必须走模型自带的 chat template**(本仓库的 `chat_template.jinja`), `tokenizer.apply_chat_template` 会自动读到它。 ```python import json from transformers import AutoModelForCausalLM, AutoTokenizer MODEL = "darkhorse-code-instruct-1.5b" # 换成你的 ModelScope 仓库 id tok = AutoTokenizer.from_pretrained(MODEL) model = AutoModelForCausalLM.from_pretrained(MODEL, torch_dtype="bfloat16", device_map="auto") SYSTEM_PROMPT = """你是 darkhorse-code 编辑器的命令翻译器。把用户的话翻译成一条标准命令,只输出一行 JSON。 命令语法: open project <绝对路径> | open file <相对路径> close project|all|other|left|right|<序号> close 的序号从 0 开始,负数从右往左,-1 是最后一个 config add|remove|update|get [-g|-p|-r] =,未指定层级默认 -p new file <相对路径> | new folder <相对路径> | new py|rs|md|c <名称> del <相对路径> | run <名称>=<命令> | run del <名称> rename <旧相对路径> <新相对路径> project lang <语言> <绝对路径> | project delete <绝对路径> | project migrate project edit "<路径>" "<名称>" <语言> 规则: 1. 语言取值 unknown/mix/java/c/python/rust/web/golang/document/kotlin;中文表达要映射:Python→python、Go→golang、前端→web、文档→document、混合→mix、未知→unknown。 2. 绝对路径(以 / 或 C:/ D:/ 开头)只允许用在 open project、project lang/edit/delete 和 config 的值里;出现在 open file、new、del、rename 时一律拒绝,reply 固定为「您没有权限!」。 3. 路径里的正斜杠输出一律转成反斜杠。 4. 运行目标只能存项目配置,不能存全局;用户要求存全局时 reply 固定为「运行目标不能保存为全局」。 5. config 的值含空格时用双引号包裹。上下文给出已有运行目标时,新增运行目标索引 = 最大索引 + 1。 6. 不是私域命令的输入(闲聊、问知识、要你写文档)用 chat:reply 不超过 20 字;一句话说不完 20 字时 reply 固定为「你还是好好工作吧,房贷还清了吗?车贷还清了吗?」。 7. 同义命令输出规范形式:删除类写 del,关闭其他文件写 close other,重命名写 rename,新建目录写 new folder,run 的删除写 run del。 输出格式(只输出一行 JSON,不要解释、不要代码块): {"intent":"command","cmd":"<标准命令>"} {"intent":"reject","reply":"<拒绝原因>"} {"intent":"chat","reply":"<短回复>"}""" def translate(text: str, context: str | None = None) -> dict: # 上下文按训练时的约定另起一行:'[上下文] <内容>' user = f"[上下文] {context}\n{text}" if context else text msgs = [{"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user}] prompt = tok.apply_chat_template(msgs, tokenize=False, add_generation_prompt=True) ids = tok(prompt, return_tensors="pt", add_special_tokens=False).to(model.device) out = model.generate(**ids, max_new_tokens=128, do_sample=False, # 贪心,别采样 pad_token_id=tok.pad_token_id or tok.eos_token_id) raw = tok.decode(out[0][ids["input_ids"].shape[1]:], skip_special_tokens=True).strip() return json.loads(raw) # 建议外面再包一层 try:小模型偶尔会多写一句解释 print(translate("把倒数第三个文件关掉")) # {'intent': 'command', 'cmd': 'close -3'} print(translate("加一个运行目标 测试=pytest -q", context="当前项目: D:/Projects/demo")) # {'intent': 'command', 'cmd': 'run 测试=pytest -q'} ``` ## 5. vLLM 部署(推荐,OpenAI 兼容) 8 GB 显存(如 RTX 5060 Laptop)即可跑 bf16: ```bash vllm serve ./qwen2.5-1.5b-cmd-merged \ --served-model-name darkhorse-code-instruct \ --chat-template ./qwen2.5-1.5b-cmd-merged/chat_template.jinja \ --dtype bfloat16 --max-model-len 32768 \ --gpu-memory-utilization 0.85 --max-num-seqs 32 \ --enable-prefix-caching --port 8001 ``` ```bash curl http://127.0.0.1:8001/v1/chat/completions -H "Content-Type: application/json" -d '{ "model": "darkhorse-code-instruct", "messages": [ {"role": "system", "content": "<上面那份 SYSTEM_PROMPT 原文>"}, {"role": "user", "content": "关掉倒数第5个文件"} ], "temperature": 0, "max_tokens": 128 }' ``` 要点: - `temperature=0`(训练与评测都是贪心解码);**不要**用 `generation_config.json` 里的默认采样参数。 - 请求里的 `system` 必须是上面那份原文;本项目服务端做薄封装时会把 system 强制替换成它。 - `choices[0].message.content` 是**契约 JSON 字符串**,调用方需要 `json.loads`。 - `--enable-prefix-caching` 对这类场景收益明显:所有请求共享同一份 553 token 的 system prompt 前缀。 - 需要「输出结构绝对合法」时,可以上受约束解码(JSON Schema 限死三种信封), 代价是采样空间被约束,个别内容可能与贪心输出不同。 ### 实测性能(RTX 5060 Laptop 8G / sm_120,bf16) 同一批 15 条真实指令压测,`--max-model-len 32768 --max-num-seqs 32 --enable-prefix-caching`: | 并发 | 请求吞吐 | 生成吞吐 | 延迟 p50 | 延迟 p95 | |---|---|---|---|---| | 1 | 3.6 req/s | 72 tok/s | 251 ms | 389 ms | | 8 | 23.0 req/s | 469 tok/s | 278 ms | 425 ms | | 32 | **49.4 req/s** | **1002 tok/s** | 337 ms | 480 ms | 流式 TTFT(并发 1)**87 ms**;显存占用:权重 3.1 G + KV cache 3.46 GiB(129,680 token ⇒ 32K 上下文可并发 3.96 路)。 参考:同一模型用 CPU + transformers 逐条生成是 **4.1–6.4 s/条**(约 0.2 req/s)。 ### 在 WSL2 上部署的三个额外环境变量 vLLM 官方只发 Linux 轮子,Windows 用户通常跑在 WSL2 里;此时还需要: ```bash # 1) V2 Model Runner 需要 pinned memory/UVA,而 vLLM 在 WSL2 上默认关掉 pin memory export VLLM_WSL2_ENABLE_PIN_MEMORY=1 # 2) WSL 里若没有 gcc,Triton 运行时编译 kernel launcher 会报 "Failed to find C compiler" # 可装 gcc(sudo apt-get install -y gcc g++),或用一个 zig cc 包装脚本当 CC export CC=/path/to/your/c-compiler # 3) 默认的 FlashInfer 采样器在缺 cubin 时会 JIT(需要 nvcc);无 CUDA toolkit 时关掉它 export VLLM_USE_FLASHINFER_SAMPLER=0 ``` 另外 WSL2 无 CUDA toolkit 时建议 `--compilation-config '{"mode":0}'`(跳过 torch.compile,启动更快、不需要 C++ 编译器)。 ## 6. 评测 评测集 **131 条**,全部**未出现在训练集模板**中的说法(含 27 条刻意构造的硬样本): 中文序数词+负索引、口语化包装(「帮我/麻烦/能不能」)、上下文注入(`[上下文] 当前项目: …`)、 同义命令归一(删除/删掉/移除 → `del`)、绝对路径拒绝、运行目标索引推断… | 指标 | 基座 Qwen2.5-1.5B-Instruct | **本模型(LoRA 微调后)** | |---|---|---| | 输出是合法 JSON | 131/131 = 100% | **131/131 = 100%** | | 与标准命令**完全一致**(exact) | 39/131 = 29.8% | **122/131 = 93.1%** | > 基座 JSON 合法率也是 100%,是因为语法和输出格式就写在 system prompt 里 —— > 但「知道语法」≠「会用语法」:55 个命令族里有 39 个族的准确率发生变化, > 基座在序号、配置、闲聊、拒绝这些地方大面积翻车。 几族的对比(体现微调到底学到了什么): | 命令族 | 基座 | 本模型 | 说明 | |---|---|---|---| | `close_index_neg`(倒数第 N 个) | 0/7 | **7/7** | 中文序数词 → 负索引 | | `del`(删除/删掉/移除归一) | 5/9 | **9/9** | 同义命令规范形式 | | `new_typed`(`new py/rs/md/c` 省后缀) | 0/4 | **4/4** | 类型子命令 | | `run_add_ctx`(上下文推索引) | 0/5 | **5/5** | `max+1` 索引推断 | | `reject_abs_dir` / `reject_abs_file` | 0/4 / 0/3 | **4/4 / 3/3** | 固定拒绝话术 | | `open_project_fwd`(正斜杠归一) | 0/3 | **3/3** | `/` → `\` | | `chat_long`(超 20 字固定话术) | 0/3 | **3/3** | 逐字背下话术 | | `config_*`(add/get/update/remove) | 0/1~0/3 | **多数转正** | 仍是最弱的一族,见局限 | **未满分的族(诚实列出,共 8 个)**:`config_get` 2/3、`config_remove` 1/3、 `hard/config` 2/3、`hard/close_index` 1/2、`hard/open_project` 1/2、`hard/reject_abs` 1/2、 `hard/reject_run_global` 0/1、`chat_short` 0/1。 典型错误是**配置层级判断**:用户说「去掉配置项 X」时,模型输出 `config remove -p X`, 而标准答案是 `config remove -r X`(这类「默认层级到底该取谁」的语义在数据里信号不够强)。 ## 7. 输入输出示例(**真实推理输出**,取自上述评测集) ``` 输入:关掉倒数第5个文件 输出:{"intent": "command", "cmd": "close -5"} 输入:打开 C:/Users/yujia/PycharmProjects/ai-notes 输出:{"intent": "command", "cmd": "open project C:\\Users\\yujia\\PycharmProjects\\ai-notes"} (正斜杠已按规则归一为反斜杠) 输入:删除 C:/Users/yujia/PycharmProjects/tmf/src/utils.py 输出:{"intent": "reject", "reply": "您没有权限!"} (绝对路径出现在 del 上 → 固定拒绝) 输入:[上下文] 已有运行目标: target3 加个运行目标 测试=.venv\Scripts\python.exe -m uvicorn app:api --port 8000 输出:{"intent": "command", "cmd": "run 测试=.venv\\Scripts\\python.exe -m uvicorn app:api --port 8000"} 输入:把 darkhorse.code.editor.tab_size 设置成 4,存到运行时 输出:{"intent": "command", "cmd": "config add -r darkhorse.code.editor.tab_size=4"} 输入:帮我建一个 rust 的模块,叫 order_service 输出:{"intent": "command", "cmd": "new rs order_service"} 输入:D:\Projects\Python\Code-Rag 是Kotlin项目 输出:{"intent": "command", "cmd": "project lang kotlin D:\\Projects\\Python\\Code-Rag"} 输入:你觉得我该不该辞职去创业,帮我分析一下利弊 输出:{"intent": "chat", "reply": "你还是好好工作吧,房贷还清了吗?车贷还清了吗?"} ``` ## 8. 训练细节 | 项 | 取值 | |---|---| | 基座 | `Qwen/Qwen2.5-1.5B-Instruct` | | 微调方式 | LoRA(`r=16`、`alpha=32`、`dropout=0.05`),训练后**合并**成自包含权重 | | 可训练参数 | 约 1.18%(其余冻结) | | 训练数据 | **1638 条**(模板 + 槽位合成,`--seed 42` 完全可复现,不用 LLM 生成) | | 超参 | `lr=2e-4`、`warmup_ratio=0.03`、2 epoch、`max_len=704`、`micro_bs=1` × `grad_accum=8` → 410 步 | | 耗时 / 显存 | 45.1 分钟 / 峰值 **7.68 GB**(单卡 8 GB 笔记本 GPU) | | loss | train 0.6818 → 0.0079(末轮均值 0.0319);eval 0.039 → 0.0092 | | 精度 | bf16 | **数据为什么是合成的**:这个任务要学的是「**照抄**」而不是「记住」—— 命令里大量是路径、命令串、 文件名。生成器把它们做成随机槽位,模型必须学会把输入里的路径原样搬进输出,而不是背样本; 同时保证可复现、零 API 成本、不会把外部 LLM 的错误学进去。 **三个刻意设计的难点**:① 约 15% 样本带上下文行,既要会用上下文补全、也要会忽略无关上下文; ② 随机加「帮我/麻烦/能不能/顺手」前缀与「吧/,谢谢/呀」后缀,放大表达多样性; ③ 27 条硬样本**只进评测集**,用来量真实泛化而不是量记忆。 ## 9. 已知局限(请务必先读) 1. **只懂 darkhorse-code 的命令语**。换个 CLI/编辑器的命令体系,必须重新造数据微调。 2. **不是聊天模型**:闲聊会被压成 ≤20 字短回复,甚至回那句固定的「劝退」话术 —— 这是设计目标,不是 bug。 3. **`config` 族最弱**(见 §6):配置层级(`-g/-p/-r`)的默认值判断会出错,接入方应对 `config` 类输出做二次校验(层级缺失时按自己的业务规则补默认值)。 4. **输出偶尔会被解析层救回来**:小模型可能把 JSON 包在 ```json 代码块里、或路径里写单反斜杠 (非法 JSON 转义)。建议接入方保留「抠 JSON + 修非法转义」的解析兜底,而不是直接 `json.loads`。 5. **上下文格式是约定**:必须写成 `[上下文] <内容>` 并**另起一行**再接用户原话, 这是训练时的格式,别自由发挥。 6. **1.5B 的常识/推理上限**:超出命令翻译的语义理解(比如反讽、多轮澄清)不要指望它。 ## 10. 文件说明 | 文件 | 说明 | |---|---| | `model.safetensors` | bf16 合并权重(约 2.9 GB),自包含、无需 adapter | | `chat_template.jinja` | **模型配套的 chat template**,请务必用它(vLLM `--chat-template` 指向它) | | `config.json` / `generation_config.json` | Qwen2 结构(28 层、2 KV head、`max_position_embeddings=32768`) | | `tokenizer.json` / `tokenizer_config.json` / `vocab.json` / `merges.txt` / `added_tokens.json` | tokenizer 全套 | | `configuration.json` | ModelScope 标记文件 | ## 11. 许可与致谢 - 本模型以 **Apache-2.0** 许可发布,与基座 `Qwen/Qwen2.5-1.5B-Instruct` 一致; 使用前请同时遵守基座模型的许可条款。 - 感谢 Qwen 团队开源的 Qwen2.5 系列。 - 训练所用数据为由模板与槽位**程序生成**的合成数据,不含任何真实用户数据或第三方隐私内容。