【从零开始】19. 模型实测与验证

在真正开讲之前需要补充一下"16. 基于 CPU 的转换、量化实现"和"18. 持续优化模型微调"中遗漏的信息。

"基于 CPU 的转换、量化实现"内容补充

在这章我通过 Python 导出基于 OpenVINO 的 IR 模型后并没有将对应的 tokenizer 导出。这会导致调用 apply_chat_template 进行推理时出现以下报错:

bash 复制代码
[2025-11-25 11:20:07,191]_apply_template - Template application failed, using raw string: Check '!chat_tpl.empty()' failed at ../../../../src/cpp/src/tokenizer/tokenizer.cpp:655:
Chat template wasn't found. This may indicate that the model wasn't trained for chat scenario. Please add 'chat_template' to tokenizer_config.json to use the model in chat scenario. For more information see the section Troubleshooting in README.md

为此,这里补上 export_tokenizer 函数用于导出 tokenizer 配置。

python 复制代码
def export_tokenizer(self):
    # 导入 openvino_tokenizers
    from openvino_tokenizers import convert_tokenizer
    # 导入 openvino
    from openvino import save_model

    # 使用 huggingface 模式加载量化后的 tokenizer
    hf_tokenizer = AutoTokenizer.from_pretrained(
        self.unsloth_merge_model_dir,
        trust_remote_code=True
    )

    # 对 tokenizer 进行转换
    ov_tokenizer, ov_detokenizer = convert_tokenizer(hf_tokenizer, with_detokenizer=True)

    # 导出基于 ov 的 tokenizer 和 detokenizer 
    save_model(ov_tokenizer, os.path.join(self.openvino_int4_model_dir,"openvino_tokenizer.xml"))
    save_model(ov_detokenizer, os.path.join(self.openvino_int4_model_dir,"openvino_detokenizer.xml"))

这里我们不能直接使用 Huggingface 的 tokenizer_config.json(虽然上面报错说 tokenizer_config.json 中找不到 'chat_template' 参数)。事实上由于我们使用的是 OpenVINO 方案,因此需要对 Huggingface 的 hf_tokenizer 后进行进一步的转换( convert_tokenizer -> save_model)后方可使用。

我们可以将 export_tokenizer 追加到 start_to_export 函数末尾即可:

python 复制代码
def start_to_export(self):

    ...

    try:
        self.export_tokenizer()
    except Exception:
        logger.error("FATAL: Tokenizer export failed. Cannot proceed with openvino-genai.")
        sys.exit(5)

    logger.info("Done.")

"持续优化模型微调"内容补充

其实 1.2287 并不是我的最终答案。后来我趁放假补充了训练数据量后又做了三次调优。最终模型得分(Loss)定格在 0.8698。超参数组合如下:

bash 复制代码
训练数据:678915
最终得分 (Loss): 0.8698
验证集损失 (Eval Loss): 0.8698
训练集损失 (Train Loss): 0.8652
最优超参数组合:
{
  "learning_rate": "2e-4",
  "max_steps": 7500,
  "r": 256,
  "lora_alpha": 512,
  "per_device_train_batch_size": 32,
  "gradient_accumulation_steps": 8,
  "max_seq_length": 8192
}

经复盘发现继续微调下去意义不是很大,毕竟基于现有的资源(硬件资源和数据资源)不会有太大的性能提升,因此后面将以这个作为基线开展工作(除非接下来的"问答验证"环节中评估分数太低,这时就需要调整数据精度并重新训练)。


好啦,书接上回。上一章节我们已经做完模型的持续调优了,接下来就需要将模型导出部署并进行"问答验证"。

看过我博客的小伙伴都知道,之前我尝试过 Optimum Intel 套件直接驱动 Qwen1.5 7B 的 GGUF 模型(属于实验性质),在单用户提问时响应速度还算可以,但准确率嘛就有点"吓人"了。除了幻觉严重外,自控能力还非常有限(无限输出)。但毕竟是低版本模型嘛,可以理解...可以理解。

这次我带着微调后的 Qwen3 0.6B 模型又来挑战了,除了使用垂直领域知识进行微调外,在 OpenVINO 驱动上相较于之前做了大量的改进。同样在自己的 MBP 上进行验证,希望能有个好结果吧。

1.OpenVINO Model Server(OVMS)与思维链

由于 Qwen3 模型是带有思维链模式的,看了一遍 OpenVINO 的官方文档,目前只有 OpenVINO Model Server (OVMS) 提供 --reasoning_parser 参数用于思考链推理,而且也只支持 qwen3。

OVMS 目前只支持 Linux 和 Windows 平台,我大 Mac 只能选择 Docker 解决方案。

事不宜迟,Checkout 最新镜像(openvino/model_server:latest)后启动 OVMS 服务。

bash 复制代码
docker run -d \
-p 8000:8000 \
-p 9000:9000 \
-v <int4_path>/models:/models \
openvino/model_server:latest \
--model_path /models/model1 \
--model_name Qwen3-0.6R \
--reasoning_parser qwen3 \
--rest_port 8000 \
--port 9000 

使用以上命令启动后得到输出

bash 复制代码
2025-11-28 14:50:36 error parsing options - unmatched arguments: --reasoning_parser, qwen3, 

What?

不是说 reasoning_parser 参数可以用的吗?之后我又尝试将 reasoning_parser 放入环境变量。虽说没有报错了,但思考链没有生效。既然解决不了就先放下,于是得到以下命令:

bash 复制代码
docker run -d \
-p 8000:8000 \
-p 9000:9000 \
-v <int4_path>/models:/models \
openvino/model_server:latest \
--model_path /models/model1 \
--model_name Qwen3-0.6R \
--rest_port 8000 \
--port 9000 

这里需要着重说明的是,参数里挂载目录传参和 model_path 参数之间的关系。首先 OVMS 使用的模型一定要是 IR 模型(我猜的,毕竟我只有 IR 模型运行成功了),因此 <int4_path> 要填写量化后导出的 int4 模型路径。但为什么后面要有一个 models 呢?这是为了配合 model_path 参数使用的需要。目录结构如下:

bash 复制代码
OpenVINOModel
└── INT4
    └── models
        └── model1
            └── 1
                ├── config.json
                ├── generation_config.json
                ├── openvino_config.json
                ├── openvino_detokenizer.bin
                ├── openvino_detokenizer.xml
                ├── openvino_model.bin
                ├── openvino_model.xml
                ├── openvino_tokenizer.bin
                └── openvino_tokenizer.xml

由于 OVMS 启动时会扫描 model_path 路径下的目录(/models/model1),其中 /models 是固定项,这个属于挂载点。下一级目录 model1 可以是任意的名字,但再下级就需要一个名为 1 的文件夹。这个 1 代表的是模型的版本(version),如果缺失了这个目录,就会一直报找不到版本(No version found for model in path)。

而 docker 命令中 model_name 是为了调用时有一个明确的 model 指向,因此为了不与 0.6B 混淆,这里就重命名为 0.6R。

至此,docker 的 OVMS 应该就能启动成功了(docker logs 看一下就知道了)。

在浏览器输入地址 http://localhost:8000/v1/config 就能够查看模型加载效果

json 复制代码
{
  "Qwen3-0.6R": {
    "model_version_status": [
      {
        "version": "1",
        "state": "AVAILABLE",
        "status": {
          "error_code": "OK",
          "error_message": "OK"
        }
      }
    ]
  }
}

好了,服务部署好了就尝试推理吧。

写一个 Python 脚本试试多轮对话:

python 复制代码
import requests
import json

base_url="http://localhost:8000/v3/chat/completions"
headers = {
    "Content-Type": "application/json"
}
payload = {
    "model": "Qwen3-0.6R",
    "stream": False,
    "messages": [
        {"role": "system", "content": "你是一名专业的中医药知识问答质量评估员。"},
        {"role": "user", "content": "请详细分析一下中医理论中'阴阳'的概念,并给出思考过程。"}
    ]
}

response = requests.request("POST", base_url, json=payload, headers=headers)
if response.status_code == 200:
    results = json.loads(response.text)["results"]
    print(results)

返回的结果如下:

python 复制代码
openvino Error code: 404 - {'error': 'Mediapipe graph definition with requested name is not found'}

对此官方文档给出提示

是的,我明白还需要 graph.pbtxt 这个文件了。但是我的 IR 导出并没有将 graph.pbtxt 生成出来。

那么 graph.pbtxt 我要在哪里才能够找到呢?经过一番寻找最终在 Github 的某一个 Issus 中找到了答案。

需要先访问 github.com/openvinotoo... 项目中找到 demos -> continuous_batching -> graph.pbtxt。找到 graph.pbtxt 后下载并拷贝到 <int4_path>/models/model1/1 目录下跟 int4 量化模型放在一起就可以了。如下图:

python 复制代码
OpenVINOModel
└── INT4
    └── models
        └── model1
            └── 1
                ├── config.json
                ├── graph.pbtxt
                ├── generation_config.json
                ├── openvino_config.json
                ├── openvino_detokenizer.bin
                ├── openvino_detokenizer.xml
                ├── openvino_model.bin
                ├── openvino_model.xml
                ├── openvino_tokenizer.bin
                └── openvino_tokenizer.xml

就这样 OVMS 启动时就能扫描到了。

好了,现在再试试调用吧。结果...

bash 复制代码
Mediapipe execution failed. MP status - INVALID_ARGUMENT: CalculatorGraph::Run() failed: \\nCalculator::Process() for node \\"LLMExecutor\\" failed: This servable accepts only single message requests

What?This servable accepts only single message requests?不是吧,只支持单次推理?(这里有点搞不懂了,可能是我的 graph.pbtxt 有问题吧,因为之后我尝试过直接用 Optimum Intel 驱动导出的 IR 模型是可以多轮问答的)

OK,就目前这种情况其实就能放弃这个方案了,但为了追求真相我们还是换成单次推理试试:

python 复制代码
import requests
import json

base_url="http://localhost:8000/v3/completions"
headers = {
    "Content-Type": "application/json"
}
payload = {
    "model": "Qwen3-0.6R",
    "stream": False,
    "prompt": "你是一名专业的中医药知识问答质量评估员。请详细分析一下中医理论中'阴阳'的概念,并给出思考过程。"
}

response = requests.request("POST", base_url, json=payload, headers=headers)
if response.status_code == 200:
    results = json.loads(response.text)["results"]
    print(results)

这次 OVMS 确实接收到请求了,但是... CPU 一直拉升并且超长时间内没有响应,如下图:

之后足足等了 6 分钟还是没有输出。这台 Mac 的 CPU 分配给 docker 的拢共就只能走 400%,等待的时间内一直被占满,最后实在没有办法了就中断了启动试验。

bash 复制代码
2025-11-28 13:47:30 [2025-11-28 05:47:30.483][84][llm_executor][info][llm_executor.hpp:66] All requests: 1; Scheduled requests: 1; Cache usage 0.1%;
...
2025-11-28 13:53:53 [2025-11-28 05:53:53.597][84][llm_executor][info][llm_executor.hpp:66] All requests: 1; Scheduled requests: 1; Cache usage 4.2%;

也许是我的配置有问题,也可能是我的硬件设备已经不支持这套方案了...也许这套方案在其他硬件或者配置下会有所改善,但就我目前的情况来看是走不通的。

2.OpenVINO GenAI 推理

2.1 OVMS 导出模型

既然 OVMS 走不通,那么我们就选择另一种推理方式 GenAI。

为什么选择 GenAI?这就要说说它与 Optimum Intel 之间的区别。

简单来说,Optimum Intel 更适合从 Huggingface 生态平滑迁移的开发者,而 GenAI 则提供了更高性能、更低依赖的本地化部署选择。

之前之所以使用 Optimum Intel 是因为那时刚刚接触模型开发,在接触 OpenVINO 之前就已经写好了本地基于 Huggingface 的 Qwen1.5 推理底座了。为了能够平滑过度到 CPU 驱动的高性能方案才使用的 Optimum Intel。但这次我们是以性能有限,因此重新选择了对 C++ 更为友好的 GenAI 解决方案。

此外,还有一个原因是后续我有可能会自己开发一个具有边缘推理能力的 APP,这时候 GenAI 就派上用场了(毕竟依赖极少,轻量化且有完整的文本生成流水线,这些对于边缘算力来说是非常重要的)。

ε=(´ο`*)))唉扯远了,回归主题。

在 OVMS 方案中我们使用 SDK 导出 IR 模型时其实是有缺失的情况出现的。这次我们使用官方推荐的导出方式 export_model.py。我在 Github 中找到 openvinotoolkit 项目,并下载 export_model.pyrequirements.txt 两个文件。下载下来后先安装 requirements.txt ,然后在 step2_export_openvino_model.py 中加入 export_model.py 导出的适配代码,如下图:

python 复制代码
def export_with_ovms_script(self):
    # 读取 requirements.txt 文件并进行安装
    export_script_path = self.install_ovms_depend()

    # 设置 config.json 文件的导出路径
    config_path = os.path.join(self.openvino_model, 'config_all.json')
    
    # 构建结构化的 OVMS 导出脚本
    cmd = [
        sys.executable, export_script_path,
        'text_generation',
        '--source_model', self.unsloth_merge_model_dir,
        '--model_repository_path', self.openvino_ovms_model_dir,
        '--model_name', self.model_name,
        '--weight-format', self.weight_format,
        '--target_device', self.target_device,
        '--config_file_path', config_path,
        '--cache_size', str(self.cache_size),
        '--max_num_seqs', str(self.max_num_seqs),
        '--max_num_batched_tokens', str(self.max_num_batched_tokens),
        '--reasoning_parser', self.reasoning_parser,
        '--extra_quantization_params', '--sym --group-size 128'
    ]
    
    # 根据 enable_prefix_caching 配置决定是否加上 --enable_prefix_caching 参数
    if self.enable_prefix_caching:
        cmd.append('--enable_prefix_caching')
    
        try:
            # 运行导出
            result = subprocess.run(cmd, capture_output=True, text=True, check=True)
            # 如果出现 warning 和 error 就进行输出
            if result.stderr:
                logger.warning(f"OVMS export stderr: {result.stderr}")
            
            # 设置导出状态为 True
            self.ovms_exported = True
            logger.info("OVMS export completed successfully")
            return True
        except subprocess.CalledProcessError as e:
            return False

导出后的文件目录如下:

bash 复制代码
OVMS
`-- qwen3-reasoning
    |-- added_tokens.json
    |-- chat_template.jinja
    |-- config.json
    |-- generation_config.json
    |-- graph.pbtxt
    |-- merges.txt
    |-- openvino_detokenizer.bin
    |-- openvino_detokenizer.xml
    |-- openvino_model.bin
    |-- openvino_model.xml
    |-- openvino_tokenizer.bin
    |-- openvino_tokenizer.xml
    |-- special_tokens_map.json
    |-- tokenizer.json
    |-- tokenizer_config.json
    `-- vocab.json

但是...

如果直接使用官方提供的 export_model.py 脚本导出是会出现报错的。如下图:

让我们来对照一下 export_model.py 源码和 huggingface 的说明文档。通过上面报错我们可以定位到导出语句:

可以看到在 export_model.py 中导出依然采用的是 optimum-cli export。而关于 optimum-cli export 我们早已在 huggingface 中看过:hf-mirror.com/docs/optimu...

bash 复制代码
usage: optimum-cli export openvino [-h] -m MODEL [--task TASK] [--framework {pt}] [--trust-remote-code]
                                   [--weight-format {fp32,fp16,int8,int4,mxfp4,nf4,cb4}]
                                   [--quant-mode {int8,f8e4m3,f8e5m2,cb4_f8e4m3,int4_f8e4m3,int4_f8e5m2}]
                                   [--library {transformers,diffusers,timm,sentence_transformers,open_clip}]
                                   [--cache_dir CACHE_DIR] [--pad-token-id PAD_TOKEN_ID] [--ratio RATIO] [--sym]
                                   [--group-size GROUP_SIZE] [--backup-precision {none,int8_sym,int8_asym}]
                                   [--dataset DATASET] [--all-layers] [--awq] [--scale-estimation] [--gptq]
                                   [--lora-correction] [--sensitivity-metric SENSITIVITY_METRIC]
                                   [--quantization-statistics-path QUANTIZATION_STATISTICS_PATH]
                                   [--num-samples NUM_SAMPLES] [--disable-stateful] [--disable-convert-tokenizer]
                                   [--smooth-quant-alpha SMOOTH_QUANT_ALPHA]
                                   output

optional arguments:
  -h, --help            show this help message and exit
...

通过对比发现在 export_model.py 中缺少了 --task 参数,这样的话 optimum-cli export 是无法知道你要导出的是什么类型的。为此,我们还需要修改一下 export_model.py 源码。如下图:

这样才能够正常导出。

2.2 GenAI 推理

模型导出后我们将使用 GenAI 管道的方式加载模型并实现推理。

整体代码我全部写在 step3_openvino_runtime.py 脚本中了,各位感兴趣可以自行 Checkout 下来阅读。在这里我只说代码中几个重点实现。

2.2.1. 线程、队列与生成器(异步流式处理模式)

代码中采用了 Python 异步 I/O 密集型任务的经典模式:生产者-消费者模型。

通过 threading.Lock 确保 OpenVINO 推理过程是互斥的。blocking=False 实现了非阻塞的系统繁忙检测:当有任务正在运行时,新请求会被快速拒绝(返回 "系统繁忙"),而不是排队阻塞,这极大地提高了服务的响应速度和稳定性。

python 复制代码
def transfor_stream_msg(
        self,
        msg: Union[str, List[Dict[str, str]]],
        **kwargs
    ) -> Generator[Dict[str, str], None, None]:
    
    # 检查是否线程繁忙
    if not OpenvinoRuntime._inference_lock.acquire(blocking=False):
        logger.warning("Inference request denied: System is busy.")
        yield {"content": "系统繁忙,请稍后再试,CPU资源已被占用。","finished": True,"stop_reason": "busy_lock"}
        return

    try:
            prompt = self._build_prompt(msg)
            early_stop_cfg = self.config["early_stop"]
            timeout = kwargs.get("timeout_seconds", early_stop_cfg["timeout_seconds"])

            # 初始化生产者
            token_queue: Queue = Queue()
            streamer = ChunkingStreamer(
                token_queue=token_queue,
                stop_strings=self.stop_strings,
                timeout_seconds=timeout,
                chunk_size=15, 
            )

而 Queue.Queue 是生产者(后台推理线程)和消费者(主生成器线程)之间的数据通道。它在线程安全的环境下缓存生成的 Subword,解耦了推理速度与消费速度。

将耗时的 self.pipe.generate(...) 放在一个独立的后台线程 run_generation 中运行。这使得主线程能够立即从队列中消费数据并 yield,避免了阻塞,实现了真正的异步流式输出。

python 复制代码
...
    # 初始化错误数组用于存放生成错误
    generation_error = [None]
    
    # 持续推理函数
    def run_generation():
        try:
            
            # GenAI 管道推理
            self.pipe.generate(prompt, gen_config, streamer)
        except Exception as e:
            generation_error[0] = e
            logger.error(f"Generation error: {e}")
        finally:
            streamer.end()

    # 独立线程运行
    gen_thread = threading.Thread(target=run_generation, daemon=True)
    gen_thread.start()

...
2.2.2. 模型定制与高效停止机制

针对康养、大健康、药食同源做了严格的提示词约束。

python 复制代码
SYSTEM_PROMPT_TCM = """
        你是康养问答助手。

        【强制规则】
        1. 只用中文回答,禁止英文
        2. 禁止使用任何符号:' " ` * # @ $ % ^ & ( ) [ ] { } < > / \ | ~
        3. 标点只用:,。、!?
        4. 回答完毕输出【EOA】后立即停止

        【输出格式】
        【证型】具体证型名称
        【调理】分点建议
        【EOA】

        【举例】
        问题:经常头晕乏力,脸色发白
        回答:
        【证型】气血两虚证
        【调理】一、饮食方面,多吃红枣、桂圆、枸杞煮粥,每周炖一次乌鸡汤或猪肝汤补血
            二、作息方面,晚上十一点前入睡,午休半小时
            三、运动方面,每天散步二十分钟,不宜剧烈运动
        【EOA】
        
        注意:禁止输出英文单词。禁止输出特殊符号。
        """

在提示词中强制要求回答结束后输出 【EOA】,并将其加入 stop_strings。模型一旦生成此标记,ChunkingStreamer 会立即切断生成,避免了不必要的 token 生成和计算浪费。

python 复制代码
class OpenvinoRuntime:

    ...

    DEFAULT_CONFIG = {
       ...

        # 停止关键字
        "stop_strings": [
            "<|im_end|>",
            "<|endoftext|>",
            "<|im_start|>",
            "\n\n\n",
            "【EOA】",
            "\nUser:",
            "\nuser:",
            "Human:",
            "Assistant:",
        ]
    }
    ...

class ChunkingStreamer:
    
    ...

    def _stop_and_trim(self, stop_str: str, last_subword: str):
        
        # 获取完整分段字符串
        full_check_string = self.buffer + last_subword

        # 字符串中查找词停关键字并获取位置
        stop_pos = full_check_string.find(stop_str)
        if stop_pos != -1:
            
            # 定位到字符串词停为止并获取在此之前的全部字符串内容
            content_to_send = full_check_string[:stop_pos]
            if content_to_send:
                
                # 将需要返回的字符串放入数据通道
                self.token_queue.put(content_to_send)
                
            # 更新字符缓存区和获取内容
            current_buffer_len = len(self.buffer)
            self.generated_text = self.generated_text[:len(self.generated_text) - current_buffer_len] + content_to_send
            self.buffer = ""
        
        # 停止继续输出
        self._request_stop(f"stop_string: {stop_str}")
2.2.3. ChunkingStreamer 的三重防御与平滑输出

上面已经浅浅提到了 ChunkingStreamer,其实 ChunkingStreamer 实现流式体验和数据清洗的核心模块,它同时处理了过滤、缓冲和停止。

2.2.3.1 关于 标签的过滤

call 方法内,通过一个 while 循环实现状态机

python 复制代码
    def __call__(self, subword: str) -> bool:
        # 先检查是否已处于停止信号已发送状态,若是则中断输出
        if self.stop_requested.is_set():
            return True
        
        # 检查输出是否超时,超时则中断输出
        if time.time() - self.start_time > self.timeout_seconds:
            self._flush_buffer_and_stop("timeout")
            return True

        # 将输出词赋值给 processed_subword
        processed_subword = subword
        
        # 状态机设置
        while True:
            if self.in_thought_mode:
                
                # 寻找</think>标签
                end_pos = processed_subword.find(THINK_END_TAG)
                if end_pos == -1:
                    # 找不到返回 False
                    return False
                else:
                    # 找到了就退出思考模式
                    self.in_thought_mode = False
                    processed_subword = processed_subword[end_pos + len(THINK_END_TAG):]
                    if not processed_subword: return False
            else:
                # 找<think>标签
                start_pos = processed_subword.find(THINK_START_TAG)
                # 若没有找到则不用找了直接跳出状态机
                if start_pos == -1:
                    break
                else:
                    # 找到的话就开始设值
                    safe_content = processed_subword[:start_pos]
                    if safe_content:
                        self.buffer += safe_content
                        self.generated_text += safe_content
                        self.token_count += 1

                    self.in_thought_mode = True
                    processed_subword = processed_subword[start_pos + len(THINK_START_TAG):]
                    if not processed_subword: return False
        
        # 检查输出词是否具有词停关键字
        temp_text = self.buffer + processed_subword
        for stop_str in self.stop_strings:
            if stop_str in temp_text:
                self._stop_and_trim(stop_str, processed_subword.strip())
                return True

        # 经过了以上检查后,没有问题了就可以将输出词放入缓冲区
        self.buffer += processed_subword
        self.generated_text += processed_subword
        self.token_count += 1

        # 如果缓冲区内字符大于预设置
        if len(self.buffer) >= self.chunk_size:
            # 找到最后一个缓冲的词
            split_pos = -1
            for p in self.punctuation:
                pos = self.buffer.rfind(p)
                if pos > split_pos:
                    split_pos = pos

            # 将所有缓存区的内容发送到数据通道
            content_to_send = ""
            if split_pos != -1 and len(self.buffer) - (split_pos + 1) < self.chunk_size:
                content_to_send = self.buffer[:split_pos + 1]
                self.buffer = self.buffer[split_pos + 1:]
            else:
                content_to_send = self.buffer[:self.chunk_size]
                self.buffer = self.buffer[self.chunk_size:]
            self.token_queue.put(content_to_send)
        return False

当检测到 标签时进行状态切换,并丢弃标签内所有内容,直到检测到 标签结束。这实现了对思考内容的实时、零延迟拦截。

此外,代码中还提供两层额外保障:

  1. 队列消费端 token.replace(THINK_END_TAG, ''),确保 标签不残留在流中。
  2. get_clean_texts 中使用 re.sub(r'.*?', '', text, flags=re.DOTALL),作为对最终完整输出的终极清理,捕获并移除任何意外残留的完整思考内容。
python 复制代码
    def get_clean_texts(self) -> str:
        text = self.generated_text
        # 删除残留的 <think> 标签
        text = re.sub(r'<think>.*?</think>', '', text, flags=re.DOTALL)
        # 删除词停关键字
        for stop_str in self.stop_strings:
            text = re.sub(re.escape(stop_str), '', text, flags=re.I)
        text = re.sub(r'\n{3,}', '\n\n', text)
        return text.strip()

2.2.3.2 强制分块与语义平滑

为了更好地检测到【EOA】终止符,强制设定了每次向队列发送数据的最小字符数。即使模型输出速度很快,内容也会被强制按 15 个字符左右的批次释放。然后就能够基于这 15 个字符一次性检测是否具有【EOA】终止符,若存在则终止输出。(这个也是小模型最麻烦的地方,输出不会"刹车"。不得已才采取这种实现方案)

此外,当 buffer 达到 chunk_size 后,代码会优先在缓冲区中寻找最近的标点符号 (。, !, ?, ;, \n) 进行切分。这确保了输出的平滑性同时,尽量避免在句子中间硬性截断,提升了用户阅读体验。

2.2.3.3 停止词检测与精确切除

还记得上面展示过的 _stop_and_trim 函数吗?当检测到 stop_str(如 【EOA】)时,该方法会计算停止词的精确位置 (stop_pos)。它只将停止词之前的内容 (full_check_string[:stop_pos]) 发送到队列,并立即设置停止标志,确保停止词本身和其后的任何多余内容都不会进入最终输出,实现了干净的自停止。

2.2.4. 推理性能

好了,现在就可以正式测试一下推理效果了,为此我编写了一个测试代码

python 复制代码
if __name__ == '__main__':

    logger.info("=" * 60)
    logger.info("Qwen3 0.6B OpenVINO Runtime 推理测试")
    logger.info("=" * 60)

    # 为了跟 Optimum Intel 套件进行完整的对比,这里用回之前的提示词
    input_prompt = "中医药理论是否能解释并解决全身乏力伴随心跳过速的症状?"

    # 计算 tokens 数
    enc = tiktoken.get_encoding("cl100k_base")

    try:
        # 获取 runtime 实例
        llm = OpenvinoRuntime()
        logger.info(f"提示词: {input_prompt}")
        logger.info("=" * 60)
        full_response = ""
        token_count = 0
        start_time = time.time()
        
        # 流式输出获取内容
        for chunk in llm.transfor_stream_msg(input_prompt):
            if not chunk["finished"]:
                print(chunk["content"], end="", flush=True)
                full_response += chunk["content"]
                token_count += len(enc.encode(chunk["content"]))
            else:
                if 'full_response' in chunk:
                    final_text = chunk['full_response']
                    if not full_response:
                        print(final_text)
                        token_count += len(enc.encode(final_text))
        
        # 记录结束时间进行耗时计算
        end_time = time.time()
        duration = end_time - start_time
        logger.info("=" * 60)
        logger.info(f"总用时: {duration:.2f} 秒")
        logger.info(f"总生成 token 数: {token_count}")
        if duration > 0:
            logger.info(f"生成速率: {token_count/duration:.2f} token/秒")
        logger.info("=" * 60)
    except Exception as e:
        logger.error(f"主程序运行失败: {e}")

输出的结果如下:

python 复制代码
...
[2025-12-02 11:53:48,828]<module> - ============================================================
[2025-12-02 11:53:48,828]<module> - Qwen3 0.6B OpenVINO Runtime 推理测试
[2025-12-02 11:53:48,828]<module> - ============================================================
[2025-12-02 11:53:49,027]_init_components - Loading model from /Users/yuanzhenhui/Documents/document_file/tmp/trained_models/20251119/OpenVINOModel/OVMS/qwen3-reasoning on CPU...
[2025-12-02 11:53:53,040]_init_components - Model loaded successfully (Mock)
[2025-12-02 11:53:53,040]_setup_generation_config - Generation config: do_sample=False, rep_penalty=1.2000000476837158, max_tokens=300
[2025-12-02 11:53:53,040]__init__ - OpenvinoRuntime initialized successfully
[2025-12-02 11:53:53,040]<module> - 提示词: 中医药理论是否能解释并解决全身乏力伴随心跳过速的症状?
[2025-12-02 11:53:53,040]<module> - ============================================================
从整体来看,全身性疲劳与心率加快在中医学上可以归结为"气虚血弱"或"阳气不足"的表现。这通常需要通过调整身体内环境来恢复平衡。推荐使用如黄芪、当归等补气养血药物进行治疗,并结合饮食调养(例如多食用含铁质的食物)以及适当运动以增强体质。同时,在专业医生指导下进行个性化管理及监测,确保安全有效。请勿自行用药。[注] 中医强调个体差异性和复杂系统作用机制,请具体诊断需由有经验的专业医疗人员完成。[2025-12-02 11:53:59,713]<module> - ============================================================
[2025-12-02 11:53:59,713]<module> - 总用时: 6.67 秒
[2025-12-02 11:53:59,713]<module> - 总生成 token 数: 228
[2025-12-02 11:53:59,714]<module> - 生成速率: 34.17 token/秒
[2025-12-02 11:53:59,714]<module> - ============================================================

与之前的 Optimum Intel 方案和 Llama.cpp 方案相比,GenAI 在性能上有明显的提升。本机 2 GHz 四核Intel Core i5 CPU 能有 34.17 token/秒是之前未曾尝试过的。若迁移到生产的 Linux 服务器稍微再调优一下应该能够满足一般前置推理要求。

3.问答验证

既然已经完成了基于 CPU 的推理部署工作了,接下来时候验证推理的准确性了。

虽然 Unsloth 训练时用 1% 的数据进行验证,但这毕竟是基于数学层面的验证。在中文的语义语法上、表达上是否清晰、不含歧义...这只能够实际模拟一下才知道。但我并不是行业专家无法就推理结果进行判断,这个时候就需要商用大模型帮我完成这个判断、评估、打分的工作了。

3.1 生成问题

有看过我博客的小伙伴又知道了,我之前曾经使用过 RAGChecker 为之前公司的推理应用做过评估。这次的验证也可以参考 RAGChecker 的处理流程,只不过为了"零投入",使用"人工收集 + Python 数据处理脚本 + 多次分批处理 + 商用大模型"的模式进行处理:

Step1 用商用大模型生成一定数量的问题(这次我就生成 5000 条不同类型的问题吧)

Step2 使用需要评估的模型对这些问题进行作答,答案需要记录起来与问题一一对应

Step3 将问题和答案都导出到 Excel 中,使用多个商用大模型对同一份 Excel 进行评分

Step4 将所有评分记录重新导入数据库并采用平均分的方式获取最终评分

听上去好像没有什么毛病。好,那就立刻开干。

先导出常用的 183 种中药材名称并编写提示词让 Qwen3-MAX 帮我生成 5000 条不同的问题先。

为了保证问题的质量,这里加上联网搜索让生成出来的问题更加完整。此外,为了提高响应速度最终按每次 1000 条记录分批生成。

3.2 生成答案

将 5000 条问题导入到 SQLite 后进行数据清洗,排重后 5000 条数据剩下 3370 条。

python 复制代码
    def insert_all_validate_data_to_sqlite(self):
        
        # 遍历目录下的所有 txt 文件
        for i in os.listdir(self.dataset_path):
            if 'txt' not in i:
                continue
            new_path = self.dataset_path + i   
            with open(new_path, "r", encoding="utf-8") as f:
                try:
                    loop_index = 0
                    
                    # 打开文件遍历每一行,将其全部插入到 SQLite 中
                    for idx,line in enumerate(f):
                        loop_index = idx
                        question = line.split(". ")[1]
                        data = {"question":question.strip()}
                        self.sql.insert("check_dataset_qa",data)
                except:
                    logger.info(f"error insert index: {loop_index}")
            logger.info(f"{i} inserted completed...")
            
    def clean_dulplicate_data(self):

        # 分组查询获取重复问题的最大 id 值
        sql = "select question,max(id) id from check_dataset_qa group by question having count(1) > 1"
        results = self.sql.fetch_all(sql)
        while len(results) > 0:

            # 删除重复数据
            ids = [result[1] for result in results]
            ids_str = ','.join(map(str, ids))
            del_sql = f"delete from check_dataset_qa where id in ({ids_str})"
            self.sql.execute(del_sql)
            results = self.sql.fetch_all(sql)
        logger.info(f"delete dulpicate data completed...")

遍历 3370 条记录并将"问题"提交给 0.6B 进行回答推理

python 复制代码
    def generate_answers(self):

        # 初始化 openvino 对象
        ort = OpenvinoRuntime()
        sql = "SELECT id,question FROM check_dataset_qa WHERE answer IS NULL LIMIT 10"
        results = self.sql.fetch_all(sql)
        batch_count = 0
        while len(results) > 0:

            # 分批次遍历获取问题和对应的 id 值
            for id,question in results:
                answer = ""

                # 推理流式返回,将所有回答收集起来
                for chunk in ort.transfor_stream_msg(question):
                    if not chunk["finished"]:
                        answer += chunk["content"]
                    else:
                        if 'full_response' in chunk:
                            final_text = chunk['full_response']
                            if not answer:
                                answer += final_text

                # 最后根据 id 进行回答字段的更新
                update_sql = f"UPDATE check_dataset_qa SET answer = '{answer}' WHERE id = {id}"
                self.sql.execute(update_sql)
                logger.info(f"Update id: {id} completed...")
            results = self.sql.fetch_all(sql)
            batch_count += 1
            logger.info(f"Generate batch {batch_count} answers completed...")

3.3 导出 CSV 并用大模型评分(Qwen3-MAX、DeepSeek 和 KIMI 2)

已回答的记录分批导出成 CSV 文件并提交给商用大模型进行评分,以下以 DeepSeek 界面为例:

如上图所示,提示词中让商用大模型直接返回 id 和评分的 JSON 数据,这样就方便我们进行数据库字段更新了。(这里因为要做到零成本所以没有调用 API 接口,而是通过文档导入导出的方式,在 Web 端获取结果)

将 Web 端返回的结果制作成 json 文件,并导入到 SQLite

python 复制代码
    def load_json_and_update_sqlite(self):
        for i in os.listdir(self.score_path):
            new_path = self.score_path + i
            type = i.split(".")[0]  
            json_data = CommonUtil.load_json_file(new_path)
            for json_obj in json_data:
                id = json_obj['id']
                score = json_obj['score']
                update_sql = f"update check_dataset_qa set {type}_score = {score} where id = {id}"
                self.sql.execute(update_sql)
                logger.info(f"Update id: {id} completed...")
                
            logger.info(f"{type} inserted completed...")

3.4 汇总评分(全 SQL 操作)

待全部分数导入完毕后,就可以计算每条记录的大模型平均分了,有了每条记录的平均分就可以计算整体回答质量的平均分。如下图所示:

最终,整个模型评分为 6.607。

整体质量在合格线之上,评分等级数据统计如下:

平均分 占比
<6 2.67%
>=6 and <7 36.87%
>=7 and <8 57.32%
>=8 and <9 3.12%
>=9 0%

通过表格可知,推理返回结果基本能贴题但不够全面,侧面说明训练数据"面"还不够广。并且经抽查发现推理结果含有大量"杂音",这说明还需要在训练精度和提示词上下点功夫才行。

总的来说就是资源(数据、硬件、时间等)还不够,就当前结果而言个人觉得已经在能力范围内做到最好了。

直到模型使用 CPU 方案落地的那一刻为止目前还没有用过一分钱(严格坚守自己的原则),后续只要继续优化调整即可。

坦白说,虽然最终未能实现思考链推理有点遗憾,但性能和质量的确是上来了(相比上一年的 Qwen1.5 版本)。每次跨越一小步就可以了,毕竟个人力量怎能与企业集团相比呢。

如果没有办法做多轮精准应答,那么可以改变策略,做边缘推理也行。就像我上面说的那样,可以考虑将这个模型放入 APP 里面。用户可以在本地算力下做一些简短的推理,之后再根据业务需求决定是否发送到云端进行深层推理。那这就能做很多东西了...

好了,以上就是本章分享的全部内容了,代码均发布到 brain-mix 项目中,欢迎各位的指导。

gitee:gitee.com/yzh0623/bra...

github:github.com/yzh0623/bra...

(未完待续...)

相关推荐
NAGNIP1 天前
一文搞懂深度学习中的通用逼近定理!
人工智能·算法·面试
冬奇Lab1 天前
一天一个开源项目(第36篇):EverMemOS - 跨 LLM 与平台的长时记忆 OS,让 Agent 会记忆更会推理
人工智能·开源·资讯
冬奇Lab1 天前
OpenClaw 源码深度解析(一):Gateway——为什么需要一个"中枢"
人工智能·开源·源码阅读
AngelPP2 天前
OpenClaw 架构深度解析:如何把 AI 助手搬到你的个人设备上
人工智能
宅小年2 天前
Claude Code 换成了Kimi K2.5后,我再也回不去了
人工智能·ai编程·claude
九狼2 天前
Flutter URL Scheme 跨平台跳转
人工智能·flutter·github
ZFSS2 天前
Kimi Chat Completion API 申请及使用
前端·人工智能
warm3snow2 天前
Claude Code 黑客马拉松:5 个获奖项目,没有一个是"纯码农"做的
ai·大模型·llm·agent·skill·mcp
天翼云开发者社区2 天前
春节复工福利就位!天翼云息壤2500万Tokens免费送,全品类大模型一键畅玩!
人工智能·算力服务·息壤
知识浅谈2 天前
教你如何用 Gemini 将课本图片一键转为精美 PPT
人工智能