没有NVIDIA显卡,基于 OpenVINO_GenAI 框架在Intel集显GPU上也能流畅本地运行14B大模型

一、OpenVINO_GenAI 框架介绍

在本专栏的上篇文章中,介绍了使用 OpenVINO 分别在 CPU、NPU、核显 GPU 上加速过 YOLO 系列模型的推理,对 OpenVINO 的推理能力已经有了比较直观的感受。

基于 Intel Ultra 处理器中的 NPU、核显 GPU 加速 YOLO12 模型推理运算

而大模型的推理流程要复杂得多,一次完整的生成包含分词、多轮自回归解码、KV cache 管理、采样策略、停止条件判断等环节,如果继续用 OpenVINO Runtime 的原生 API 手工实现这些逻辑,代码量会迅速膨胀。

OpenVINO GenAI 正是为了解决这个问题而推出的,它是构建在 OpenVINO Runtime 之上的一层生成式 AI 高层库,专门封装了大模型的完整流水线,如:自动处理 tokenizer、循环解码、KV Cache、流式输出、采样策略等,主打本地推理、轻量、低依赖、以及结合 Intel 硬件加速本地推理场景。

其中流水线预置多种常用流水线,覆盖文本生成 LLMPipeline、多模态模型VLMPipeline 等,其中 LLMPipeline 是本文下面实验使用到的,初始化之后调用 generate 就能完成一轮对话。

本次实验模型层面本文选取 Qwen3-14B 模型,在推理能力、指令遵循和多语言能力之间取得了不错的平衡。实验会在核显 GPU 上完成 对话推理、工具调用,并封装为 OpenAI 协议接口供上层应用调用。

本次实验使用的硬件配置:

设备类型 型号
CPU Intel® Core™ Ultra X7 358H
核显 GPU Intel® Arc™ B390 GPU (iGPU)

相关的依赖版本如下:

shell 复制代码
openvino-genai==2026.4.0.0
openvino==2026.4.0
optimum-intel==2.2.0
nncf==3.4.0
transformers==5.5.4
modelscope==1.34.0
fastapi>=0.115
uvicorn>=0.30
tavily-python>=0.5

二、下载 Qwen3-14B 模型

下载模型,这里使用 modelscope 加速下载:

shell 复制代码
modelscope download --model="Qwen/Qwen3-14B" --local_dir Qwen3-14B

下载完成后的内容:

三、模型转化为OV模型

LLMPipeline 需要使用 OpenVINO IR 格式的模型,首先第一步需要做格式的转换,optimum-intel 依赖包已经实现了相关的转换功能,具体可以参考如下逻辑,本质是执行了 optimum_cli 指令,这里导出的过程做了量化处理,转化为了 Int4 格式,可以极大的减少内存的占用情况。

注意将 Qwen3-14B 模型的地址换成你电脑的路径:

python 复制代码
import os
import subprocess
import sys

# 待导出的 HuggingFace 模型
HF_MODEL = "F:/model/Qwen3-14B"

# 导出目标目录
OV_MODEL_DIR = "ov_qwen3_14b"
TASK = "text-generation-with-past"


def validate_ir(out_dir):
    """
    校验导出的 IR 是否带 KV cache(LLMPipeline 必须)

    :param out_dir: OpenVINO IR 模型目录
    :return: (是否可用, 输入名列表, 输出名列表)
    """
    import openvino as ov

    model = ov.Core().read_model(os.path.join(out_dir, "openvino_model.xml"))
    inputs = [i.get_any_name() for i in model.inputs]
    outputs = [o.get_any_name() for o in model.outputs]

    # 带 KV cache 的 decoder 模型:状态(KV cache)被隐藏进 model 内部,
    # 所以 outputs 只有 logits,要通过 get_sinks() 查内部状态,并检查 beam_idx 输入
    num_states = len(model.get_sinks())
    has_beam_idx = any("beam_idx" in n for n in inputs)
    has_past = any("present" in n for n in outputs)

    print(f"  输入: {inputs}")
    print(f"  输出: {outputs}")
    print(f"  内部状态数 (KV cache): {num_states}")

    # stateful 导出:KV cache 是内部状态 + 有 beam_idx 输入
    # 非 stateful 导出:KV cache 以 present.* 暴露在 outputs 里
    if not has_beam_idx and not has_past:
        print("\n  [失败] IR 校验未通过:没有 KV cache。")
        return False, inputs, outputs

    print("  [通过] IR 校验通过(带 KV cache)")
    return True, inputs, outputs


def export_int4(hf_model=HF_MODEL, out_dir=OV_MODEL_DIR, weight_format="int4",
                task=TASK):

    # 用 -m 调模块而不是直接调 optimum-cli,保证用的是同一个解释器
    cmd = [
        sys.executable, "-m", "optimum.commands.optimum_cli", "export", "openvino",
        "--model", hf_model,
        "--task", task,
        "--weight-format", weight_format,
        "--trust-remote-code",
        out_dir,
    ]
    print("执行导出命令:")
    print(" ".join(cmd))
    print()
    subprocess.run(cmd, check=True)
    print(f"\n导出完成: {out_dir}")
    print("校验 IR:")
    validate_ir(out_dir)

    return out_dir


if __name__ == "__main__":
    export_int4()

执行过程:

转换模型内容:

四、运行模型

模型转换完成后就可以加载运行了。使用 ov_genai.LLMPipeline 构建 LLM 流水线,把前面导出的 IR 目录和推理设备传进去即可。

为了让运行结果便于观察,下面把推理过程封装成了一个 OpenVINOChat 类,还通过 Pipeline 的性能指标接口采集了首 token 延迟和吞吐数据:

python 复制代码
from dataclasses import dataclass
from pathlib import Path
import time

import openvino_genai as ov_genai

# OpenVINO IR 模型目录
MODEL_DIR = "ov_qwen3_14b"
# 使用设备 GPU / CPU
DEVICE = "GPU"
# 最大生成 token 数
MAX_NEW_TOKENS = 1024
SYSTEM_PROMPT = "你叫小毕超,是一个超级人工智能助手"


@dataclass
class ChatResult:
    text: str
    token_count: int
    first_token_time: float | None
    total_time: float
    throughput: float | None


class OpenVINOChat:
    def __init__(self, model_dir=MODEL_DIR, device=DEVICE,
                 max_new_tokens=MAX_NEW_TOKENS, system_prompt=SYSTEM_PROMPT):
        if max_new_tokens < 1:
            raise ValueError("max_new_tokens 必须大于 0")
        model_path = Path(model_dir)
        if not model_path.is_absolute():
            model_path = Path(__file__).resolve().parent / model_path
        self.device = device
        self.max_new_tokens = max_new_tokens
        self.system_prompt = system_prompt
        self.pipe = ov_genai.LLMPipeline(str(model_path), device)
        self.tokenizer = self.pipe.get_tokenizer()
        self.reset()

    def reset(self):
        self.messages = [{"role": "system", "content": self.system_prompt}]

    @staticmethod
    def _streamer(fragment):
        print(fragment, end="", flush=True)
        return False

    def chat(self, question: str, stream: bool = True, *, extra_context: dict | None = None) -> ChatResult:
        if not question.strip():
            raise ValueError("问题不能为空")
        user_message = {"role": "user", "content": question}
        prompt = self.tokenizer.apply_chat_template(
            self.messages + [user_message],
            add_generation_prompt=True,
            extra_context=extra_context,
        )
        config = ov_genai.GenerationConfig()
        config.max_new_tokens = self.max_new_tokens
        # prompt 已应用聊天模板,生成时不再重复应用。
        config.apply_chat_template = False

        start = time.perf_counter()
        result = self.pipe.generate([prompt], config, self._streamer if stream else None)
        total_time = time.perf_counter() - start
        metrics = result.perf_metrics
        token_count = metrics.get_num_generated_tokens()
        response = ChatResult(
            text=result.texts[0],
            token_count=token_count,
            first_token_time=metrics.get_ttft().mean / 1000 if token_count else None,
            total_time=total_time,
            throughput=metrics.get_throughput().mean if token_count > 1 else None,
        )
        # 成功生成后再更新历史,失败的请求不留在对话中。
        self.messages.extend([user_message, {"role": "assistant", "content": response.text}])
        return response


def print_metrics(result: ChatResult, device: str):
    print(f"\n{'-' * 50}")
    print(f"设备: {device}")
    print(f"生成 token 数: {result.token_count}")
    ttft = f"{result.first_token_time:.3f}s" if result.first_token_time is not None else "N/A"
    throughput = f"{result.throughput:.2f} token/s" if result.throughput is not None else "N/A"
    print(f"首 token 延迟: {ttft}")
    print(f"总耗时: {result.total_time:.2f}s")
    print(f"吞吐: {throughput}")
    print(f"{'-' * 50}\n")


def main():
    print(f"加载模型: {MODEL_DIR} @ {DEVICE}...")
    start = time.perf_counter()
    chat = OpenVINOChat()
    print(f"编译完成,耗时 {time.perf_counter() - start:.2f}s\n")
    try:
        while True:
            question = input("请输入:")
            if not question.strip():
                continue
            if question == "q":
                break
            result = chat.chat(question)
            print_metrics(result, chat.device)
    except (EOFError, KeyboardInterrupt):
        print()


if __name__ == "__main__":
    main()

运行效果如下,执行期间可以随意提出对话问题:

推理速度平均在 14 token/s,这个速度谈不上很快,但已经超过了人正常的阅读速度,本地使用的话速度还是可以接受的,而且整个过程中 CPU 占用很低,浏览网页、写代码等其他操作基本不受影响。

以下是运行期间 CPU、GPU占用情况:

Qwen3 默认是开启思考的,如果需要关闭思考可在 extra_context 中添加:{"enable_thinking": False},例如:

python 复制代码
def main():
    print(f"加载模型: {MODEL_DIR} @ {DEVICE}...")
    start = time.perf_counter()
    chat = OpenVINOChat()
    print(f"编译完成,耗时 {time.perf_counter() - start:.2f}s\n")
    try:
        while True:
            question = input("请输入:")
            if not question.strip():
                continue
            if question == "q":
                break
            result = chat.chat(question, extra_context={"enable_thinking": False})
            print_metrics(result, chat.device)
    except (EOFError, KeyboardInterrupt):
        print()

再次运行提问:

五、工具调用示例

只要模型支持工具调用,使用 LLMPipeline 同样也支持工具调用,以下是对上述调用过程增加了工具调用的支持的逻辑,并且增加了一个网络搜索的工具作为测试使用:

注意:网络搜索采用的 Tavily ,可以将你的 API KEY 放到环境变量 TAVILY_API_KEY 中,或者换成其他的测试工具

python 复制代码
from dataclasses import dataclass, field
import json
import os
from pathlib import Path
import time
import uuid
from typing import Literal

import openvino_genai as ov_genai
from tavily import TavilyClient

MODEL_DIR = "ov_qwen3_14b"
DEVICE = "GPU"
MAX_NEW_TOKENS = 1024
SYSTEM_PROMPT = "你是一个超级人工智能助手。需要最新或不确定的信息时,先调用 web_search 搜索,回答时引用来源链接。"
MAX_TOOL_ROUNDS = 4

TOOLS = [{"type": "function", "function": {
    "name": "web_search",
    "description": "搜索互联网,获取最新信息、新闻及相关来源链接。",
    "parameters": {"type": "object", "properties": {
        "query": {"type": "string", "description": "搜索关键词或需要查询的问题。"},
        "max_results": {"type": "integer", "description": "最多返回的搜索结果数量,范围为 1 至 20,默认返回 5 条。", "minimum": 1, "maximum": 20, "default": 5},
        "topic": {"type": "string", "description": "搜索主题:general 表示通用搜索,news 表示新闻搜索,finance 表示财经搜索;默认为通用搜索。", "enum": ["general", "news", "finance"], "default": "general"},
        "include_raw_content": {"type": "boolean", "description": "是否返回网页原始内容,默认不返回。", "default": False},
    }, "required": ["query"], "additionalProperties": False},
}}]


def web_search(query: str, max_results: int = 5,
               topic: Literal["general", "news", "finance"] = "general",
               include_raw_content: bool = False):
    api_key = os.environ.get("TAVILY_API_KEY")
    if not api_key:
        raise ValueError("Set TAVILY_API_KEY before using web_search")
    return TavilyClient(api_key=api_key, timeout=30).search(
        query, max_results=max_results, topic=topic, include_raw_content=include_raw_content,
    )


@dataclass
class ChatResult:
    text: str
    token_count: int
    first_token_time: float | None
    total_time: float
    throughput: float | None
    tool_calls: list[dict] = field(default_factory=list)


def parse_tool_calls(text: str):
    # Tool markup inside thinking is not an actual tool request.
    thinking, separator, answer = text.partition("</think>")
    if separator:
        prefix, remaining = thinking + separator, answer
    elif text.lstrip().startswith("<think>"):
        return text, []
    else:
        prefix, remaining = "", text
    parts, calls = [prefix], []
    while "<tool_call>" in remaining:
        before, _, remaining = remaining.partition("<tool_call>")
        payload, end, remaining = remaining.partition("</tool_call>")
        if not end:
            raise ValueError("Incomplete tool call; increase max_new_tokens")
        call = json.loads(payload)
        if not isinstance(call, dict) or not isinstance(call.get("name"), str) or not call["name"]:
            raise ValueError("Invalid tool name")
        arguments = call.get("arguments")
        if isinstance(arguments, str):
            arguments = json.loads(arguments)
        if not isinstance(arguments, dict):
            raise ValueError("Tool arguments must be a JSON object")
        calls.append({"id": "call_" + uuid.uuid4().hex, "type": "function", "function": {
            "name": call["name"], "arguments": json.dumps(arguments, ensure_ascii=False),
        }})
        parts.append(before)
    return "".join(parts) + remaining, calls


class OpenVINOChat:
    def __init__(self, model_dir=MODEL_DIR, device=DEVICE,
                 max_new_tokens=MAX_NEW_TOKENS, system_prompt=SYSTEM_PROMPT):
        if max_new_tokens < 1:
            raise ValueError("max_new_tokens must be positive")
        model_path = Path(model_dir)
        if not model_path.is_absolute():
            model_path = Path(__file__).resolve().parent / model_path
        self.device = device
        self.max_new_tokens = max_new_tokens
        self.system_prompt = system_prompt
        self.pipe = ov_genai.LLMPipeline(str(model_path), device)
        self.tokenizer = self.pipe.get_tokenizer()
        self.reset()

    def reset(self):
        self.messages = [{"role": "system", "content": self.system_prompt}]
        self._pending_tool_calls = set()

    @staticmethod
    def _streamer(fragment):
        print(fragment, end="", flush=True)
        return False

    def add_tool_result(self, tool_call_id: str, content: str):
        if tool_call_id not in self._pending_tool_calls or not isinstance(content, str):
            raise ValueError("Supply an unanswered tool_call_id and string result")
        self.messages.append({"role": "tool", "tool_call_id": tool_call_id, "content": content})
        self._pending_tool_calls.remove(tool_call_id)

    def chat(self, question: str | None = None, stream: bool = True, *,
             tools: list[dict] | None = None, extra_context: dict | None = None) -> ChatResult:
        if self._pending_tool_calls:
            raise ValueError("Submit all pending tool results first")
        if question is None and self.messages[-1]["role"] != "tool":
            raise ValueError("Supply a question or return tool results before continuing")
        if question is not None and (not isinstance(question, str) or not question.strip()):
            raise ValueError("question must not be empty")
        messages = self.messages.copy()
        if question is not None:
            messages.append({"role": "user", "content": question})
        prompt = self.tokenizer.apply_chat_template(
            messages, add_generation_prompt=True, tools=tools, extra_context=extra_context,
        )
        config = ov_genai.GenerationConfig()
        config.max_new_tokens = self.max_new_tokens
        config.apply_chat_template = False
        start = time.perf_counter()
        result = self.pipe.generate([prompt], config, self._streamer if stream else None)
        total_time = time.perf_counter() - start
        text, calls = parse_tool_calls(result.texts[0])
        metrics = result.perf_metrics
        token_count = metrics.get_num_generated_tokens()
        response = ChatResult(
            text=text, token_count=token_count,
            first_token_time=metrics.get_ttft().mean / 1000 if token_count else None,
            total_time=total_time,
            throughput=metrics.get_throughput().mean if token_count > 1 else None,
            tool_calls=calls,
        )
        assistant = {"role": "assistant", "content": text}
        if calls:
            assistant["tool_calls"] = calls
        self.messages = messages + [assistant]
        self._pending_tool_calls = {call["id"] for call in calls}
        return response


def print_metrics(result: ChatResult, device: str):
    ttft = f"{result.first_token_time:.3f}s" if result.first_token_time is not None else "N/A"
    throughput = f"{result.throughput:.2f} token/s" if result.throughput is not None else "N/A"
    print(f"\n{'-' * 50}")
    print(f"Device: {device}; generated tokens: {result.token_count}")
    print(f"Time to first token: {ttft}; total time: {result.total_time:.2f}s")
    print(f"Throughput: {throughput}")
    print(f"{'-' * 50}\n")


def main():
    print(f"加载模型: {MODEL_DIR} @ {DEVICE}...")
    start = time.perf_counter()
    chat = OpenVINOChat()
    print(f"编译完成,耗时 {time.perf_counter() - start:.2f}s\n")
    options = {"tools": TOOLS, "extra_context": {"enable_thinking": False}}
    try:
        while True:
            question = input("请输入:")
            if not question.strip():
                continue
            if question == "q":
                break
            result = chat.chat(question, **options)
            print_metrics(result, chat.device)
            for _ in range(MAX_TOOL_ROUNDS):
                if not result.tool_calls:
                    break
                for call in result.tool_calls:
                    function = call["function"]
                    print(f"\n[tool] {function['name']}: {function['arguments']}")
                    # Tool execution belongs to the caller, not OpenVINOToolChat.
                    try:
                        if function["name"] != "web_search":
                            raise ValueError(f"Unknown tool: {function['name']}")
                        output = web_search(**json.loads(function["arguments"]))
                    except Exception as exc:
                        output = {"error": str(exc)}
                    chat.add_tool_result(call["id"], json.dumps(output, ensure_ascii=False))
                result = chat.chat(**options)
                print_metrics(result, chat.device)
            if result.tool_calls:
                print("Tool round limit reached; resetting the conversation.")
                chat.reset()
    except (EOFError, KeyboardInterrupt):
        print()


if __name__ == "__main__":
    main()

测试运行效果:

六、封装为 OpenAI 格式供上层应用使用

如果想接入 Cherry Studio 这类现成的客户端,或者让其他程序统一调用,就需要把功能封装成通用的接口。目前各类 AI 客户端和开发框架基本都支持 OpenAI 的 Chat Completions 协议,因此下面把 LLMPipeline 封装成一个兼容该协议的 HTTP 服务。

6.1 将上述调用过程封装为 OpenAI 协议接口

python 复制代码
import argparse
import asyncio
from concurrent.futures import TimeoutError as FutureTimeout
from contextlib import asynccontextmanager
from dataclasses import dataclass
from importlib.machinery import EXTENSION_SUFFIXES
import json
import logging
import os
from pathlib import Path
import secrets
import sys
import threading
import time
from typing import Any, Literal
import uuid
from fastapi import Depends, FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse, StreamingResponse
import openvino_genai as ov_genai
from pydantic import BaseModel, ConfigDict, Field
from starlette.exceptions import HTTPException
import uvicorn

logger = logging.getLogger("openvino_api")


class APIError(Exception):
    def __init__(self, message, status=400, param=None, code=None):
        self.status = status
        self.body = {"error": {
            "message": message,
            "type": "server_error" if status >= 500 else "invalid_request_error",
            "param": param,
            "code": code,
        }}


class Message(BaseModel):
    model_config = ConfigDict(extra="allow")
    role: Literal["system", "developer", "user", "assistant", "tool"]
    content: str | list[dict[str, Any]] | None = None
    tool_calls: list[dict[str, Any]] | None = None
    tool_call_id: str | None = None
    reasoning_content: str | None = None


class ChatRequest(BaseModel):
    model_config = ConfigDict(extra="allow", allow_inf_nan=False)
    model: str
    messages: list[Message] = Field(min_length=1)
    stream: bool = False
    stream_options: dict[str, Any] | None = None
    max_tokens: int | None = Field(default=None, ge=1)
    max_completion_tokens: int | None = Field(default=None, ge=1)
    temperature: float | None = Field(default=None, ge=0, le=2)
    top_p: float | None = Field(default=None, gt=0, le=1)
    frequency_penalty: float = Field(default=0, ge=-2, le=2)
    presence_penalty: float = Field(default=0, ge=-2, le=2)
    seed: int | None = Field(default=None, ge=0, le=2**32 - 1)
    stop: str | list[str] | None = None
    n: int = Field(default=1, ge=1)
    tools: list[dict[str, Any]] | None = None
    tool_choice: str | dict[str, Any] | None = None
    parallel_tool_calls: bool = True
    enable_thinking: bool = False
    response_format: dict[str, Any] | None = None


@dataclass
class Settings:
    model_dir: Path = Path(__file__).resolve().parent / "ov_qwen3_14b"
    model_name: str = "qwen3-14b"
    device: str = "GPU"
    api_key: str | None = None
    default_max_tokens: int = 1024
    max_output_tokens: int = 8192
    context_length: int = 8192


def normalize_request(body: ChatRequest, settings: Settings):
    if body.model != settings.model_name:
        raise APIError(f"Model '{body.model}' is not available.", 404, "model", "model_not_found")
    if body.n != 1:
        raise APIError("Only n=1 is supported.", param="n")
    if body.response_format and body.response_format.get("type") != "text":
        raise APIError("Only response_format.type='text' is supported.", param="response_format")
    extra = body.model_extra or {}
    for key in ("functions", "function_call", "logit_bias", "prediction", "audio"):
        if extra.get(key):
            raise APIError(f"'{key}' is not supported.", param=key)
    if extra.get("logprobs") or extra.get("top_logprobs"):
        raise APIError("Log probabilities are not supported.", param="logprobs")
    if extra.get("modalities", ["text"]) != ["text"]:
        raise APIError("Only text input and output are supported.", param="modalities")
    if body.max_tokens is not None and body.max_completion_tokens is not None:
        raise APIError("Specify max_tokens or max_completion_tokens, not both.")
    limit = body.max_completion_tokens or body.max_tokens or settings.default_max_tokens
    if limit > settings.max_output_tokens:
        raise APIError(f"Output limit exceeds {settings.max_output_tokens} tokens.", param="max_completion_tokens")
    stops = [body.stop] if isinstance(body.stop, str) else body.stop or []
    if len(stops) > 4 or any(not isinstance(s, str) or not s for s in stops):
        raise APIError("stop must contain one to four nonempty strings.", param="stop")

    tools = body.tools or []
    names = set()
    for tool in tools:
        function = tool.get("function")
        if tool.get("type") != "function" or not isinstance(function, dict):
            raise APIError("Only tools of type 'function' are supported.", param="tools")
        name = function.get("name")
        if not isinstance(name, str) or not name or name in names:
            raise APIError("Tool names must be nonempty and unique.", param="tools")
        if not isinstance(function.get("parameters", {}), dict):
            raise APIError("Tool parameters must be a JSON schema object.", param="tools")
        if function.get("strict"):
            raise APIError("Strict tool schema enforcement is not supported.", param="tools")
        names.add(name)
    choice = body.tool_choice or ("auto" if tools else "none")
    forced_name = None
    if isinstance(choice, dict):
        function = choice.get("function")
        forced_name = function.get("name") if isinstance(function, dict) else None
        if choice.get("type") != "function" or forced_name not in names:
            raise APIError("tool_choice must name an available function.", param="tool_choice")
        tools = [tool for tool in tools if tool["function"]["name"] == forced_name]
    elif choice not in ("auto", "none", "required"):
        raise APIError("Invalid tool_choice.", param="tool_choice")
    if choice != "none" and not tools:
        raise APIError("tool_choice requires tools.", param="tool_choice")
    if choice == "none":
        tools = []

    messages = []
    pending_calls = set()
    for message in body.messages:
        item = message.model_dump(exclude_none=True)
        content = message.content
        if isinstance(content, list):
            if any(part.get("type") != "text" or not isinstance(part.get("text"), str) for part in content):
                raise APIError("Only text content parts are supported.", param="messages")
            content = "".join(part["text"] for part in content)
        if message.role == "developer":
            item["role"] = "system"
        if message.tool_calls:
            if message.role != "assistant":
                raise APIError("tool_calls require the assistant role.", param="messages")
            for call in message.tool_calls:
                function = call.get("function")
                if (call.get("type") != "function" or not isinstance(call.get("id"), str)
                        or not isinstance(function, dict) or not isinstance(function.get("name"), str)
                        or not isinstance(function.get("arguments"), str)):
                    raise APIError("Invalid assistant tool call.", param="messages")
                try:
                    arguments = json.loads(function["arguments"])
                except (ValueError, TypeError):
                    raise APIError("Tool arguments must be a JSON string.", param="messages") from None
                if not isinstance(arguments, dict):
                    raise APIError("Tool arguments must describe an object.", param="messages")
                if call["id"] in pending_calls:
                    raise APIError("Assistant tool call IDs must be unique.", param="messages")
                pending_calls.add(call["id"])
        if message.role == "tool":
            if message.tool_call_id not in pending_calls:
                raise APIError("tool_call_id must refer to an unanswered assistant tool call.", param="messages")
            pending_calls.remove(message.tool_call_id)
        elif pending_calls and not message.tool_calls:
            raise APIError("Return tool results before continuing the conversation.", param="messages")
        if content is None and not (message.role == "assistant" and message.tool_calls):
            raise APIError("Message content is required.", param="messages")
        item["content"] = content or ""
        messages.append(item)
    if pending_calls:
        raise APIError("The conversation has unanswered tool calls.", param="messages")
    if choice == "required" or forced_name:
        instruction = "You must call a provided function in this response."
        if forced_name:
            instruction += f" Call the function named {forced_name}."
        if messages[0]["role"] == "system":
            messages[0]["content"] += "\n" + instruction
        else:
            messages.insert(0, {"role": "system", "content": instruction})
    return messages, tools, limit, stops, choice


class OutputParser:
    """Frame Qwen3 delimiters across chunks; parse function bodies with JSON."""

    def __init__(self, tool_names, enable_thinking=False, parallel=True):
        self.tool_names = set(tool_names)
        self.parallel = parallel
        self.mode = "initial"
        self.buffer = ""
        self.content = ""
        self.reasoning = ""
        self.tool_calls = []

    @staticmethod
    def split_partial(text, marker):
        for size in range(min(len(text), len(marker) - 1), 0, -1):
            if text.endswith(marker[:size]):
                return text[:-size], text[-size:]
        return text, ""

    def feed(self, text, final=False):
        self.buffer += text
        deltas = []
        while self.buffer:
            if self.mode == "initial":
                candidate = self.buffer.lstrip()
                if not final and (not candidate or "<think>".startswith(candidate)):
                    break
                if candidate.startswith("<think>"):
                    self.buffer = candidate[len("<think>"):]
                    self.mode = "reasoning_content"
                else:
                    self.mode = "content"
                continue
            if self.mode == "tool":
                end = self.buffer.find("</tool_call>")
                if end < 0:
                    if final:
                        raise APIError("Model returned an incomplete tool call; increase max_tokens.", 502)
                    break
                payload, self.buffer = self.buffer[:end], self.buffer[end + len("</tool_call>"):]
                try:
                    call = json.loads(payload)
                    name, arguments = call["name"], call["arguments"]
                    if isinstance(arguments, str):
                        arguments = json.loads(arguments)
                    if name not in self.tool_names or not isinstance(arguments, dict):
                        raise ValueError("Invalid tool name or arguments")
                except (ValueError, TypeError, KeyError):
                    raise APIError("Model returned invalid tool call JSON.", 502) from None
                if self.tool_calls and not self.parallel:
                    raise APIError("Model returned multiple calls with parallel_tool_calls=false.", 502)
                call = {"id": "call_" + uuid.uuid4().hex[:24], "type": "function",
                        "function": {"name": name, "arguments": json.dumps(arguments, ensure_ascii=False)}}
                deltas.append({"tool_calls": [{"index": len(self.tool_calls), **call}]})
                self.tool_calls.append(call)
                self.mode = "content"
                continue
            marker = "</think>" if self.mode == "reasoning_content" else "<tool_call>"
            parse_marker = self.mode == "reasoning_content" or bool(self.tool_names)
            index = self.buffer.find(marker) if parse_marker else -1
            if index >= 0:
                fragment = self.buffer[:index]
                self.buffer = self.buffer[index + len(marker):]
                next_mode = "content" if self.mode == "reasoning_content" else "tool"
            else:
                if final or not parse_marker:
                    fragment, self.buffer = self.buffer, ""
                else:
                    fragment, self.buffer = self.split_partial(self.buffer, marker)
                next_mode = self.mode
            if fragment:
                if self.mode == "reasoning_content":
                    self.reasoning += fragment
                else:
                    self.content += fragment
                deltas.append({self.mode: fragment})
            self.mode = next_mode
            if index < 0:
                break
        if final and self.mode == "tool":
            raise APIError("Model returned an incomplete tool call; increase max_tokens.", 502)
        return deltas

    def message(self):
        message = {"role": "assistant", "content": self.content or (None if self.tool_calls else "")}
        if self.reasoning:
            message["reasoning_content"] = self.reasoning
        if self.tool_calls:
            message["tool_calls"] = self.tool_calls
        return message


class OpenVINOEngine:
    def __init__(self, settings):
        self.settings = settings
        self.inference_lock = threading.Lock()
        logger.info("Loading %s on %s", settings.model_dir, settings.device)
        self.pipe = ov_genai.LLMPipeline(str(settings.model_dir), settings.device)
        self.tokenizer = self.pipe.get_tokenizer()
        model_config = json.loads((settings.model_dir / "config.json").read_text(encoding="utf-8"))
        self.context_length = min(settings.context_length, model_config.get("max_position_embeddings", settings.context_length))
        logger.info("Model ready; context limit: %d tokens", self.context_length)

    def generate(self, body, prepared, emit, cancelled):
        # A single pipeline has mutable inference state shared by all requests.
        with self.inference_lock:
            self._generate(body, prepared, emit, cancelled)

    def _generate(self, body, prepared, emit, cancelled):
        messages, tools, limit, stops, choice = prepared
        if cancelled.is_set():
            return
        prompt = self.tokenizer.apply_chat_template(
            messages, add_generation_prompt=True, tools=tools,
            extra_context={"enable_thinking": body.enable_thinking},
        )
        inputs = self.tokenizer.encode(prompt, add_special_tokens=False)
        input_count = inputs.input_ids.shape[-1]
        if input_count + limit > self.context_length:
            raise APIError(f"Input ({input_count}) plus output limit ({limit}) exceeds context limit ({self.context_length}).", param="messages", code="context_length_exceeded")
        config = self.pipe.get_generation_config()
        config.max_new_tokens = limit
        config.apply_chat_template = False
        config.num_return_sequences = 1
        config.num_beams = 1
        config.frequency_penalty = body.frequency_penalty
        config.presence_penalty = body.presence_penalty
        if body.temperature is not None:
            config.do_sample = body.temperature > 0
            if config.do_sample:
                config.temperature = body.temperature
        if body.top_p is not None:
            config.top_p = body.top_p
        if body.seed is not None:
            config.rng_seed = body.seed
        config.stop_strings = set(stops)
        config.include_stop_str_in_output = False
        config.validate()
        parser = OutputParser([tool["function"]["name"] for tool in tools], body.enable_thinking, body.parallel_tool_calls)
        emit("ready", None)

        def streamer(fragment):
            if cancelled.is_set():
                return ov_genai.StreamingStatus.CANCEL
            for delta in parser.feed(fragment):
                if body.stream:
                    emit("delta", delta)
            return ov_genai.StreamingStatus.RUNNING

        result = self.pipe.generate(inputs, config, streamer)
        if cancelled.is_set():
            return
        for delta in parser.feed("", final=True):
            if body.stream:
                emit("delta", delta)
        if (choice == "required" or isinstance(choice, dict)) and not parser.tool_calls:
            raise APIError("Model did not produce the required tool call.", 502)
        metrics = result.perf_metrics
        prompt_tokens = metrics.get_num_input_tokens()
        completion_tokens = metrics.get_num_generated_tokens()
        reasons = result.finish_reasons
        length = bool(reasons and reasons[0] == ov_genai.GenerationFinishReason.LENGTH)
        if not reasons:
            length = completion_tokens >= limit
        finish_reason = "length" if length else "tool_calls" if parser.tool_calls else "stop"
        logger.info("Generated %d tokens; TTFT %.1f ms; %.2f tokens/s", completion_tokens,
                    metrics.get_ttft().mean, metrics.get_throughput().mean)
        emit("done", {"message": parser.message(), "finish_reason": finish_reason,
                      "usage": {"prompt_tokens": prompt_tokens, "completion_tokens": completion_tokens,
                                "total_tokens": prompt_tokens + completion_tokens}})


def emit_event(events, loop, cancelled, kind, payload):
    if cancelled.is_set():
        return
    # This buffer transfers stream fragments, not pending inference requests.
    pending = asyncio.run_coroutine_threadsafe(events.put((kind, payload)), loop)
    while True:
        try:
            pending.result(timeout=0.2)
            return
        except FutureTimeout:
            if cancelled.is_set():
                pending.cancel()
                return


async def next_event(events, cancelled, request):
    while True:
        if await request.is_disconnected():
            cancelled.set()
            raise APIError("Client disconnected.", 499)
        try:
            return await asyncio.wait_for(events.get(), timeout=0.25)
        except asyncio.TimeoutError:
            pass


def sse(payload):
    return "data: " + json.dumps(payload, ensure_ascii=False) + "\n\n"


def create_app(settings=None, engine_factory=OpenVINOEngine):
    settings = settings or Settings()

    @asynccontextmanager
    async def lifespan(app):
        generations = {}
        app.state.generations = generations
        try:
            app.state.engine = await asyncio.to_thread(engine_factory, settings)
            yield
        finally:
            active_generations = list(generations.items())
            for _, cancelled in active_generations:
                cancelled.set()
            for thread, _ in active_generations:
                await asyncio.to_thread(thread.join)

    app = FastAPI(title="OpenVINO Chat Completions API", lifespan=lifespan)

    @app.exception_handler(APIError)
    async def api_error(request, exc):
        return JSONResponse(exc.body, status_code=exc.status)

    @app.exception_handler(RequestValidationError)
    async def validation_error(request, exc):
        error = exc.errors()[0]
        param = ".".join(str(part) for part in error["loc"] if part != "body")
        return JSONResponse(APIError(error["msg"], param=param or None).body, status_code=400)

    @app.exception_handler(HTTPException)
    async def http_error(request, exc):
        return JSONResponse(APIError(str(exc.detail), exc.status_code).body, status_code=exc.status_code)

    async def authenticate(request: Request):
        if settings.api_key:
            supplied = request.headers.get("authorization", "")
            if not secrets.compare_digest(supplied.encode(), ("Bearer " + settings.api_key).encode()):
                raise APIError("Invalid API key.", 401, code="invalid_api_key")

    @app.get("/health")
    async def health():
        return {"status": "ok", "model": settings.model_name, "device": settings.device}

    def model_info():
        return {"id": settings.model_name, "object": "model", "created": 0, "owned_by": "local"}

    @app.get("/v1/models", dependencies=[Depends(authenticate)])
    async def models():
        return {"object": "list", "data": [model_info()]}

    @app.get("/v1/models/{model_id}", dependencies=[Depends(authenticate)])
    async def model(model_id: str):
        if model_id != settings.model_name:
            raise APIError("Model not found.", 404, "model", "model_not_found")
        return model_info()

    @app.post("/v1/chat/completions", dependencies=[Depends(authenticate)])
    async def completions(body: ChatRequest, request: Request):
        prepared = normalize_request(body, settings)
        loop = asyncio.get_running_loop()
        events = asyncio.Queue(maxsize=128)
        cancelled = threading.Event()

        def emit(kind, payload):
            emit_event(events, loop, cancelled, kind, payload)

        def run():
            try:
                app.state.engine.generate(body, prepared, emit, cancelled)
            except APIError as exc:
                emit("error", exc)
            except Exception:
                logger.exception("Inference failed")
                emit("error", APIError("OpenVINO inference failed. See server log.", 500))
            finally:
                loop.call_soon_threadsafe(app.state.generations.pop, thread, None)

        thread = threading.Thread(target=run, name="openvino-request")
        app.state.generations[thread] = cancelled
        thread.start()
        completion_id = "chatcmpl-" + uuid.uuid4().hex
        created = int(time.time())

        def chunk(delta, finish_reason=None):
            payload = {"id": completion_id, "object": "chat.completion.chunk", "created": created,
                       "model": settings.model_name, "choices": [{"index": 0, "delta": delta,
                       "logprobs": None, "finish_reason": finish_reason}]}
            if body.stream_options and body.stream_options.get("include_usage"):
                payload["usage"] = None
            return payload

        try:
            kind, payload = await next_event(events, cancelled, request)
            if kind == "error":
                raise payload
            if not body.stream:
                kind, payload = await next_event(events, cancelled, request)
                if kind == "error":
                    raise payload
                return {"id": completion_id, "object": "chat.completion", "created": created,
                        "model": settings.model_name, "choices": [{"index": 0, "message": payload["message"],
                        "logprobs": None, "finish_reason": payload["finish_reason"]}], "usage": payload["usage"]}
        except BaseException:
            cancelled.set()
            raise

        async def stream():
            try:
                yield sse(chunk({"role": "assistant", "content": ""}))
                while True:
                    kind, payload = await next_event(events, cancelled, request)
                    if kind == "error":
                        yield sse(payload.body)
                        break
                    if kind == "done":
                        yield sse(chunk({}, payload["finish_reason"]))
                        if body.stream_options and body.stream_options.get("include_usage"):
                            usage_chunk = chunk({})
                            usage_chunk["choices"] = []
                            usage_chunk["usage"] = payload["usage"]
                            yield sse(usage_chunk)
                        break
                    yield sse(chunk(payload))
                yield "data: [DONE]\n\n"
            except APIError:
                pass
            finally:
                cancelled.set()

        return StreamingResponse(stream(), media_type="text/event-stream",
                                 headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})

    return app


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--model-dir", type=Path, default=Settings.model_dir)
    parser.add_argument("--model-name", default="qwen3-14b")
    parser.add_argument("--device", default="GPU")
    parser.add_argument("--host", default="127.0.0.1")
    parser.add_argument("--port", type=int, default=8000)
    parser.add_argument("--context-length", type=int, default=8192)
    parser.add_argument("--max-output-tokens", type=int, default=8192)
    parser.add_argument("--default-max-tokens", type=int, default=1024)
    args = parser.parse_args()
    if min(args.context_length, args.max_output_tokens, args.default_max_tokens) < 1:
        parser.error("Token limits must be positive.")
    if args.default_max_tokens > args.max_output_tokens:
        parser.error("default-max-tokens must not exceed max-output-tokens.")
    logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
    settings = Settings(model_dir=args.model_dir.resolve(), model_name=args.model_name, device=args.device,
                        api_key=os.environ.get("OPENVINO_API_KEY"), context_length=args.context_length,
                        max_output_tokens=args.max_output_tokens, default_max_tokens=args.default_max_tokens)
    uvicorn.run(create_app(settings), host=args.host, port=args.port, workers=1)


if __name__ == "__main__":
    main()

启动服务:

6.2 使用 Cherry Studio 调用测试

添加一个自定义提供商,填写如下内容,其中 API 秘钥可以随便写,上面实现的 API 接口没有做相关的校验:

使获取模型列表,添加模型:

添加 Qwen3-14B 模型:

调用测试:

相关推荐
傲笑风1 个月前
【openvino】tinybert基于openvino服务化部署(四)
人工智能·python·自然语言处理·nlp·bert·openvino
长江后浪博客1 个月前
Conda环境下测试Intel NPU:Python版本如何选择?
开发语言·python·conda·openvino·intel npu
leoZ2311 个月前
AI+前端提效- 06 AI辅助调试排错:前端报错、白屏、兼容问题极速定位
前端·人工智能·chatgpt·状态模式·超分辨率重建·openvino·dreamfusion
刻BITTER1 个月前
让 Intel NPU 跑AI 大模型?一次 llama.cpp OpenVINO 后端的完整实测
人工智能·llama·openvino
ONE_SIX_MIX1 个月前
llama-server 部署 Qwen3.8-27B:真正开启 512K 上下文,突破 256K 截断
ai·qwen3·llama-cpp
leoZ2312 个月前
AI 辅助开发的五道坎
开发语言·人工智能·视觉检测·bert·php·超分辨率重建·openvino
椒颜皮皮虾྅2 个月前
OpenVINO C# API 3.3.1:支持OpenVINO 2026.3及GenAI增强
人工智能·c#·openvino
leoZ2312 个月前
Vue3 还原一个企业级后台-01-项目背景与选题
图像处理·人工智能·chatgpt·智慧城市·边缘计算·openvino·dreamfusion
进军的码农2 个月前
浪潮服务器怎么选?DeepSeek V4、GLM-5.2、Qwen3三大开源模型本地部署方案与代理商选购指南
大模型部署·deepseek·qwen3·浪潮服务器·gpu服务器·glm-5.2