【从零开始】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...

(未完待续...)

相关推荐
jixunwulian1 小时前
边缘计算网关在空压机数据采集与远程运维中的解决方案
运维·人工智能·边缘计算
zl_vslam1 小时前
SLAM中的非线性优-3D图优化之相对位姿Between Factor(七)
人工智能·算法·计算机视觉·3d
源码技术栈1 小时前
Java智能诊所管理系统源码 SaaS云门诊运维平台源码
java·大数据·运维·人工智能·源码·诊所·门诊
The Straggling Crow1 小时前
理解训练 vs 推理时对计算图、内存、精度的不同要求
人工智能
阿杰学AI1 小时前
AI核心知识33——大语言模型之ASR(简洁且通俗易懂版)
人工智能·ai·语言模型·自然语言处理·语音识别·asr·自动语音识别
安徽正LU o561-6o623o71 小时前
(露)转棒疲劳仪 大鼠转棒疲劳仪 小鼠转棒疲劳仪
人工智能
北京耐用通信1 小时前
工业通信升级利器:耐达讯自动化Ethernet/IP转CC-Link网关让IO模块兼容无忧!
网络·人工智能·科技·物联网·网络协议·自动化·信息与通信
用户2462932067671 小时前
标书智能体(三)——生成标书正文代码+提示词
人工智能
凌晨一点的秃头猪1 小时前
SIFT尺度不变特征变换
人工智能·计算机视觉