第 08 讲:阿加犀 AidGenSE 端侧大模型服务化与 OpenAI 兼容接口

一、为什么要把大模型"服务化"

1.1 第 7 讲留下的"启动器"问题

第 7 讲我们用 AidGen 的 C++ 接口把 Qwen2.5 搬上了犀牛派 X1,跑通了多轮对话。当时我们说得很清楚:那个 C++ 程序只是一个"启动器",负责把引擎拉起来、跑通对话这一薄层。问题也随之而来------你的业务代码大概率是 Python、是 Web 前端、是各种脚本,它们没法直接去调一个 C++ 可执行文件里的函数。

更现实的是,第 7 讲的程序是"一次启动、一个进程、一个对话"。如果你有三个 Python 脚本都想用这个大模型,难道各启一个 C++ 进程、各加载一份模型?端侧内存根本扛不住。这正是"服务化"要解决的:把大模型从"某个进程里的内部对象",变成"一个常驻的、可被多方调用的本地服务"。

1.2 服务化到底解决了什么

把 LLM 做成一个本地 HTTP 服务,收益是结构性的,不是锦上添花:

  • 模型只加载一次:服务启动时把权重和 KV Cache 结构建好,之后所有客户端共享这一份,不用每个调用方重复加载。
  • 语言解耦:只要会发 HTTP 请求,Python、Java、Node、甚至浏览器里的 JavaScript 都能调,C++ 彻底退到幕后。
  • 接口标准化:如果服务说的是"OpenAI 兼容"那套接口,你现成的、为云端 OpenAI 写的一大堆代码几乎不用改就能平移过来。
  • 进程隔离:服务挂了不影响你的业务进程,业务崩了也不动模型,排查边界清晰。

一句话,服务化把"端侧大模型"从一个"能跑的 Demo"变成一个"能集成进产品的能力"。这一讲我们就把第 7 讲的本地大模型,用 AidGenSE 封装成一个对外的本地服务。

1.3 本讲目标与硬件准备

读完本讲,你应该能:第一,理解 AidGenSE 相对 AidGen 多出来的"服务层"是什么、为什么选 OpenAI 兼容接口;第二,把 Qwen2.5 通过 AidGenSE 起成一个监听本地端口的服务;第三,用 Python 的 requests 或 openai SDK 调通,包括流式接收;第四,处理服务化带来的新坑(端口、绑定地址、并发、上下文)。

硬件上沿用第 7 讲的犀牛派 X1(QCS8550)------跑 LLM 仍然需要它的 NPU 算力与内存水位。模型也沿用第 7 讲从 Model Farm 下载的 Qwen2.5-0.5B-Instruct。如果你第 7 讲的模型已经部署好,本讲可以直接在它基础上加服务层。

1.4 进程内嵌 vs 独立服务,再权衡一次

第 7 讲 6.7 节我们对比过"进程内嵌入"和"独立服务"两种集成思路,当时的结论是服务化更灵活。这里把账算细一点,免得你选错。进程内嵌入 (引擎就在你主程序里)延迟最低、少一层网络,但绑定单一语言、模型随进程份数翻倍、崩了互相拖累;独立服务多一跳本地 HTTP 的开销(毫秒级,本地回环可忽略),换来语言无关、模型共享、故障隔离。对"一个模型、多个消费方"这种端侧常见形态,服务化几乎总是更划算------本地回环的延迟代价,远小于多加载一份模型的内存代价。


二、AidGenSE 在工具链中的位置

2.1 站在 AidGen 肩上的服务层

回忆一下工具链的分层:AidLite 是统一推理底座,AidGen 在它之上做生成式推理(KV Cache、自回归、对话模板)。AidGenSE 则再往上叠一层------把 AidGen 的推理能力包装成一个网络服务。
#mermaid-svg-AXaM4iMb4tR32mVT{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-AXaM4iMb4tR32mVT .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-AXaM4iMb4tR32mVT .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-AXaM4iMb4tR32mVT .error-icon{fill:#552222;}#mermaid-svg-AXaM4iMb4tR32mVT .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-AXaM4iMb4tR32mVT .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-AXaM4iMb4tR32mVT .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-AXaM4iMb4tR32mVT .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-AXaM4iMb4tR32mVT .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-AXaM4iMb4tR32mVT .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-AXaM4iMb4tR32mVT .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-AXaM4iMb4tR32mVT .marker{fill:#333333;stroke:#333333;}#mermaid-svg-AXaM4iMb4tR32mVT .marker.cross{stroke:#333333;}#mermaid-svg-AXaM4iMb4tR32mVT svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-AXaM4iMb4tR32mVT p{margin:0;}#mermaid-svg-AXaM4iMb4tR32mVT .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-AXaM4iMb4tR32mVT .cluster-label text{fill:#333;}#mermaid-svg-AXaM4iMb4tR32mVT .cluster-label span{color:#333;}#mermaid-svg-AXaM4iMb4tR32mVT .cluster-label span p{background-color:transparent;}#mermaid-svg-AXaM4iMb4tR32mVT .label text,#mermaid-svg-AXaM4iMb4tR32mVT span{fill:#333;color:#333;}#mermaid-svg-AXaM4iMb4tR32mVT .node rect,#mermaid-svg-AXaM4iMb4tR32mVT .node circle,#mermaid-svg-AXaM4iMb4tR32mVT .node ellipse,#mermaid-svg-AXaM4iMb4tR32mVT .node polygon,#mermaid-svg-AXaM4iMb4tR32mVT .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-AXaM4iMb4tR32mVT .rough-node .label text,#mermaid-svg-AXaM4iMb4tR32mVT .node .label text,#mermaid-svg-AXaM4iMb4tR32mVT .image-shape .label,#mermaid-svg-AXaM4iMb4tR32mVT .icon-shape .label{text-anchor:middle;}#mermaid-svg-AXaM4iMb4tR32mVT .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-AXaM4iMb4tR32mVT .rough-node .label,#mermaid-svg-AXaM4iMb4tR32mVT .node .label,#mermaid-svg-AXaM4iMb4tR32mVT .image-shape .label,#mermaid-svg-AXaM4iMb4tR32mVT .icon-shape .label{text-align:center;}#mermaid-svg-AXaM4iMb4tR32mVT .node.clickable{cursor:pointer;}#mermaid-svg-AXaM4iMb4tR32mVT .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-AXaM4iMb4tR32mVT .arrowheadPath{fill:#333333;}#mermaid-svg-AXaM4iMb4tR32mVT .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-AXaM4iMb4tR32mVT .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-AXaM4iMb4tR32mVT .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-AXaM4iMb4tR32mVT .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-AXaM4iMb4tR32mVT .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-AXaM4iMb4tR32mVT .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-AXaM4iMb4tR32mVT .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-AXaM4iMb4tR32mVT .cluster text{fill:#333;}#mermaid-svg-AXaM4iMb4tR32mVT .cluster span{color:#333;}#mermaid-svg-AXaM4iMb4tR32mVT div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-AXaM4iMb4tR32mVT .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-AXaM4iMb4tR32mVT rect.text{fill:none;stroke-width:0;}#mermaid-svg-AXaM4iMb4tR32mVT .icon-shape,#mermaid-svg-AXaM4iMb4tR32mVT .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-AXaM4iMb4tR32mVT .icon-shape p,#mermaid-svg-AXaM4iMb4tR32mVT .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-AXaM4iMb4tR32mVT .icon-shape .label rect,#mermaid-svg-AXaM4iMb4tR32mVT .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-AXaM4iMb4tR32mVT .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-AXaM4iMb4tR32mVT .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-AXaM4iMb4tR32mVT :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 客户端: Python / Web / 其他语言
AidGenSE 服务层: OpenAI 兼容 HTTP
AidGen 生成式推理
AidLite 统一推理底座
CPU / GPU / NPU

所以你之前建立的认知全部继续生效:底层还是 AidLite 调度算力,中间还是 AidGen 管生成,AidGenSE 只是在最外面加了一个"HTTP 门面"。它不替代 AidGen,而是给 AidGen 装上了一个标准接口,让不会 C++ 的代码也能用。

2.2 为什么选 OpenAI 兼容接口

市面上本地大模型服务不少,接口各不相同。AidGenSE 选择对齐 OpenAI 的 HTTP 接口(/v1/chat/completions 这一套),原因很务实:这是当前事实上的行业通用约定,围绕它有海量的现成客户端、SDK、教程和示例代码。你的项目如果之前是对着云端 OpenAI 写的,切到本地 AidGenSE 往往只需要改一个 base_url 指向本机,业务逻辑零改动。

这也是端侧落地的一个通用思路------尽量复用已有的标准接口,而不是发明一套私有协议。私有协议意味着所有调用方都要专门适配,标准接口意味着生态里的工具开箱即用。当然,"兼容"通常指核心字段对齐,具体支持到哪个程度(哪些参数生效、哪些被忽略)以 AidGenSE 当前版本文档为准。

2.3 aidllm CLI 与服务的两种用法

AidGenSE 提供命令行工具 aidllm,它通常有两种角色:一是交互式对话 (在终端里直接和模型聊,适合快速验证模型能不能用);二是启动服务 (把模型起成一个后台 HTTP 服务,供程序调用)。本讲的重点是后者,但前者很有用------服务起不来时,先用 CLI 交互模式确认模型本身能加载、能出 token,可以帮你把"模型问题"和"服务问题"分开排查。具体子命令与参数以 aidllm --help 和官方文档为准。

2.4 本地服务与云端 OpenAI 的边界

把接口做成 OpenAI 兼容,容易让人产生"它就是本地版 OpenAI"的错觉。要清醒:接口兼容不等于能力等价。本地服务跑的是 0.5B、1.5B 量级的小模型,能力和云端的大模型不在一个层级;它也没有云端那种弹性并发,同时来太多请求会排队甚至超时。所以本地 AidGenSE 的定位依然是"云端补充"------处理那些数据不出本机、断网也要能用、调用量不巨大的场景。选型时那句老话仍然适用:这数据敢不敢出本机、这网络靠不靠得住。

三、接口与模型:8888 端口背后

3.1 /v1/chat/completions 与流式 SSE

OpenAI 兼容接口的核心是聊天补全接口 /v1/chat/completions。你发一个 JSON,里面带 messages(消息列表,含 system/user/assistant 角色)和 model 等字段,服务返回模型的回复。如果请求里 stream=true,服务就用 SSE(Server-Sent Events) 逐 token 推送------这对应第 7 讲说的"流式生成",只是这次 token 是通过 HTTP 响应一条条推给客户端,而不是 C++ 回调。

理解 SSE 很关键:它不是一次性返回完整 JSON,而是保持连接、不断下推形如 data: {...} 的片段,每个片段含一小段增量文本,最后以 data: [DONE] 收尾。客户端要按这个格式逐行解析、拼接增量。第 6.3 节会给出完整的解析代码。

3.2 模型格式与加载(.gguf / .bin / .aidem)

端侧大模型有几种常见的权重格式,AidGenSE/AidGen 侧可能遇到 .gguf、.bin、.aidem 等(具体支持范围以官方文档为准)。你不必深究每种格式的内部结构,但要明白两点:一是格式要和引擎匹配 ,从 Model Farm 下载时选的就是适配 AidGen/AidGenSE 的版本,别拿别处的权重硬塞;二是格式往往已包含量化信息(比如 INT8/INT4),文件名或配套说明里会标注。加载时服务会按格式解析权重、构建推理图,这一步是启动开销的大头。

3.3 多轮对话的会话状态谁管

这是个容易踩的认知坑:HTTP 服务本身是无状态 的,服务端不会替你记对话历史。多轮对话的"记忆",靠客户端每次把完整 messages 列表(包含之前的每一轮)一起发上来。也就是说,第 7 讲 6.3 节讲的"历史怎么攒",在服务化场景下从 C++ 程序内部挪到了你的客户端代码里。你要自己维护一个消息数组,每轮把用户输入和模型回复 append 进去,下次请求整体发送------同时要控制它不超过上下文长度 cl(否则报错或被截断)。会话管理这份责任,服务化后仍在调用方。

3.4 并发与上下文长度的服务级约束

服务化了,就要考虑多个客户端同时来的情况。端侧服务的并发能力有限:每个进行中的请求都占一份 KV Cache 内存,并发越高、上下文越长,内存压力越大。本地服务不像云端能弹性扩容,超出承受就会排队、变慢甚至报错。工程上的对策:一是限制同时接入的客户端数量;二是控制每个请求的上下文长度;三是把"长上下文"和"高并发"当成一对要权衡的资源,而不是各自无限要。具体并发上限以你的真机实测与版本为准。

3.5 服务化调用的时延构成与优化方向

服务化之后,一次调用的总时延可以拆成几段,搞清楚每一段才知道该优化哪。大致是:网络往返(本地回环毫秒级、跨机看网络质量)+ 排队等待(前面有请求在跑时你得等)+ 首 token 延迟(模型从收到请求到产出第一个 token)+ 生成时长(后续 token 逐一出完)。本地回环下网络几乎可忽略,真正的重头仍是模型本身的首 token 与生成。所以优化方向和第 7 讲一致:小模型、小 cl 直接压低首 token 与生成;而"排队"这一段只和并发有关------客户端一多,后来的就得等前一个生成完。如果你发现"单客户端很快、多客户端明显变慢",瓶颈多半在排队而非网络,这时要么限并发、要么接受排队、要么换算力更高的平台。把时延拆开看,能避免把"模型慢"误诊成"网络慢"。

3.6 看懂一次完整的请求与响应

把接口字段看全,调的时候心里才有底。一次典型的非流式响应长这样(字段以实际返回为准):

json 复制代码
{
  "id": "chatcmpl-local-001",
  "object": "chat.completion",
  "created": 1717000000,
  "model": "qwen2.5-0.5b-instruct",
  "choices": [
    {
      "index": 0,
      "message": {"role": "assistant", "content": "边缘计算是把计算放到靠近数据源的设备上......"},
      "finish_reason": "stop"
    }
  ],
  "usage": {"prompt_tokens": 24, "completion_tokens": 57, "total_tokens": 81}
}

几个字段值得记住:choices[0].message.content 是模型回复本体;finish_reason 告诉你生成是正常结束(stop)还是被长度截断(length------出现它说明撞了 max_tokens 或上下文上限);usage 里的 token 统计能帮你估算上下文占用,prompt_tokens 就是你发上去的历史有多长、completion_tokens 是这次生成了多少。流式时每个片段结构类似,只是 message 换成 delta、且通常不带完整 usage。读懂这些字段,排查"回复被截断""上下文占用异常"就有抓手了。

四、环境调研:安装与版本对齐

4.1 安装 AidGenSE

和第 7 讲装 AidGen 一样,AidGenSE 也通过 aid-pkg 安装(具体包名以 AidLux 当前文档为准,形态类似 aid-pkg -i aidgense-sdk 或随 AidGen 一并提供):

bash 复制代码
sudo aid-pkg update
sudo aid-pkg install aidgense-sdk     # 包名以文档为准

装完后,aidllm 命令应可用,用 which aidllm 或 aidllm --help 确认。老规矩,装完用 aid-pkg installed 核对 AidGenSE 在列、版本正确。

4.2 版本对齐:服务 / 模型 / QNN

版本纪律在工具链里反复强调,服务化也不例外。这里要三方对齐:AidGenSE 服务的版本、底层 AidGen/AidLite/QNN 的版本、以及模型所适配的版本。比如某模型标注"需 AidGen X 及以上 + QNN Y",你的服务和底层都得满足,否则轻则起不来、重则输出乱码。建议把"模型 → AidGenSE 版本 → QNN 版本"并进第 7 讲那张版本总表,一处维护。版本对齐看着枯燥,却是端侧从"能跑"到"稳定跑"的分水岭。

4.3 端口与防火墙

服务默认监听一个本地端口(常见为 8888,具体以文档/配置为准)。起服务前确认两件事:一是这个端口没被别的进程占用(用 ss -tlnp 或 netstat 查);二是如果你打算从局域网内别的机器访问(比如开发机上的浏览器调板子上的服务),要确认服务绑定的是 0.0.0.0 而不是仅 127.0.0.1,且板子防火墙没拦这个端口。只允许本机访问的话,绑 127.0.0.1 更安全。端口与绑定地址是服务化最常翻车的两个点,提前想清楚。

五、操作步骤:把 Qwen2.5 跑成本地服务

下面以"把第 7 讲的 Qwen2.5-0.5B-Instruct 起成一个 OpenAI 兼容本地服务"为例讲流程。具体命令、参数、配置项以 AidGenSE 当前版本文档为准,这里讲不变的逻辑。

5.1 准备模型目录

确认第 7 讲的模型还在 /home/aidlux/models/qwen2.5-0.5b-instruct/,目录内含权重、配置、对话模板。服务要按这个路径加载模型------再次强调,路径一律用绝对路径(理由同第 7 讲坑点 7.1:服务以不同工作目录启动时,相对路径极易解析错)。

5.2 启动服务

用 aidllm 把模型起成服务,示意如下(具体子命令与参数以文档为准):

bash 复制代码
# 示意:以 OpenAI 兼容服务方式加载模型,监听 8888
aidllm serve \
  --model /home/aidlux/models/qwen2.5-0.5b-instruct \
  --host 0.0.0.0 \
  --port 8888 \
  --context-length 2048

启动后终端会输出加载日志,看到"服务已监听 8888"之类的提示说明起来了。如果卡在加载阶段,多半是内存不足(模型太重或 cl 太大)或路径/模板问题,回到第 7 讲的排查思路。

5.3 验证服务存活

服务起来后,先用最轻的方式探活。直接对聊天接口发一个极短请求,或在终端用 aidllm 的交互模式确认模型能对话。探活通过,说明"模型能加载 + 服务能响应"这条链路通了,再进入正式的客户端开发。这一步相当于第 2 讲的"先跑官方示例验环境"------先把成功路径跑通,再往上叠自己的代码。

5.4 调整上下文与并发

服务启动参数里的上下文长度(--context-length)和并发相关配置,要根据你的场景调。回忆第 7 讲:cl 越大 KV Cache 越吃内存。服务化后这个账要乘以并发数。建议先用保守的 cl(如 1024/2048)把服务跑稳,确认多客户端能正常收发,再按需上调,并实测内存占用是否还有余量。别一上来就开大 cl + 高并发,那是端侧服务最常见的"起得来、跑不久"的根源。

六、关键代码:Python 客户端 + 流式

服务起来了,现在用你最熟悉的 Python 来调。下面三种方式从简到全。

6.1 requests 最小调用

最朴素的方式,用 requests 直接 POST 聊天接口(非流式):

python 复制代码
import requests

url = "http://127.0.0.1:8888/v1/chat/completions"   # 板子本机;跨机换成板子 IP
payload = {
    "model": "qwen2.5-0.5b-instruct",
    "messages": [
        {"role": "system", "content": "你是一个有帮助的本地助手,回答简洁准确。"},
        {"role": "user", "content": "用一句话解释什么是边缘计算。"},
    ],
    "stream": False,
}
resp = requests.post(url, json=payload, timeout=120)
data = resp.json()
print(data["choices"][0]["message"]["content"])

字段结构对齐 OpenAI 的聊天接口:请求带 messages,响应从 choices[0].message.content 取回复。具体的可用字段、是否需鉴权头,以 AidGenSE 文档为准。

6.2 openai SDK 调用

如果你装了 openai 这个 Python 包,可以用它调本地服务------只需把 base_url 指向本机:

python 复制代码
from openai import OpenAI

client = OpenAI(base_url="http://127.0.0.1:8888/v1", api_key="not-needed")  # 本地服务通常不校验 key

resp = client.chat.completions.create(
    model="qwen2.5-0.5b-instruct",
    messages=[{"role": "user", "content": "用一句话解释什么是 NPU。"}],
)
print(resp.choices[0].message.content)

这就是 OpenAI 兼容的价值:你为标准接口写的代码,改个 base_url 就能从云端平移到本地。api_key 本地服务一般不校验,给个占位即可。

6.3 流式接收(SSE 解析)

体验上,流式远好于干等整句。用 requests 按 SSE 逐行解析增量:

python 复制代码
import requests, json

url = "http://127.0.0.1:8888/v1/chat/completions"
payload = {
    "model": "qwen2.5-0.5b-instruct",
    "messages": [{"role": "user", "content": "讲一个三句话的短故事。"}],
    "stream": True,
}
with requests.post(url, json=payload, stream=True, timeout=120) as r:
    for line in r.iter_lines(decode_unicode=True):
        if not line or not line.startswith("data:"):
            continue
        data = line[len("data:"):].strip()
        if data == "[DONE]":
            break
        delta = json.loads(data)["choices"][0]["delta"].get("content", "")
        print(delta, end="", flush=True)
print()

要点:stream=True 让 requests 保持连接;按行读,挑出 data: 开头的行;[DONE] 表示结束;每个片段的增量文本在 choices[0].delta.content。边收边打印,就是"打字机"效果。

6.4 多轮对话的客户端历史管理

服务无状态,历史要客户端自己攒。维护一个消息列表,每轮 append:

python 复制代码
from openai import OpenAI

client = OpenAI(base_url="http://127.0.0.1:8888/v1", api_key="not-needed")
messages = [{"role": "system", "content": "你是一个有帮助的本地助手。"}]

def chat(user_input):
    messages.append({"role": "user", "content": user_input})
    resp = client.chat.completions.create(model="qwen2.5-0.5b-instruct", messages=messages)
    answer = resp.choices[0].message.content
    messages.append({"role": "assistant", "content": answer})  # 把回复也并入历史
    return answer

注意这正是 3.3 节说的:服务不记历史,你每次都得把完整 messages 发上去。messages 会越攒越长,撞上 cl 上限时要做截断或摘要(思路同第 7 讲 6.3),否则报错或遗忘。

6.5 封装成可复用的 client 模块

产品里别让每个脚本都重复写请求细节,封装一个模块统一收口:

python 复制代码
# local_llm.py ------ 本地大模型客户端封装
from openai import OpenAI

class LocalLLM:
    def __init__(self, base_url="http://127.0.0.1:8888/v1", model="qwen2.5-0.5b-instruct"):
        self.client = OpenAI(base_url=base_url, api_key="not-needed")
        self.model = model
        self.messages = [{"role": "system", "content": "你是一个有帮助的本地助手。"}]

    def ask(self, text, max_history=10):
        self.messages.append({"role": "user", "content": text})
        # 控制历史长度,防止超出上下文
        self.messages = [self.messages[0]] + self.messages[-max_history:]
        resp = self.client.chat.completions.create(model=self.model, messages=self.messages)
        answer = resp.choices[0].message.content
        self.messages.append({"role": "assistant", "content": answer})
        return answer

别处只要 from local_llm import LocalLLM; llm = LocalLLM(); llm.ask("...") 就能用。把 base_url、model、历史管理、异常处理都收进这一层,业务代码就干净了。

6.6 从 Web 前端调用:浏览器里的流式与跨域

不少端侧应用的操作界面就是一个 Web 页面,直接从浏览器调本地服务也很常见。浏览器里发非流式请求用 fetch 即可;要流式,可以用 fetch 的 ReadableStream 逐块读响应体,或用 EventSource(原生支持 SSE,但它只支持 GET,而聊天接口通常是 POST,所以更通用的是 fetch + ReadableStream)。这里有个专门的坑------跨域(CORS) :如果你的网页和服务不在同一个源(主机或端口不同),浏览器会拦截跨域请求,需要服务端在响应头里放行(如 Access-Control-Allow-Origin)。AidGenSE 是否默认放行、如何配置,以官方文档为准;若不支持,可让网页与服务同源部署,或在前面加一层反向代理统一源。浏览器直连本地服务很顺手,但这层页面别随便暴露到不可信的网络里。

七、坑点

7.1 端口被占用 / 服务起不来

启动报"地址已占用"或起不来,先查端口:用 ss -tlnp | grep 8888 看是不是已有进程占了 8888(可能是上次没退干净的服务)。对策:kill 掉旧进程,或换个端口再起。

7.2 跨机访问不通(绑定地址)

本机 curl 127.0.0.1:8888 通,但从开发机访问板子 IP 不通------多半是服务只绑了 127.0.0.1。对策:起服务时把 host 设为 0.0.0.0,并确认板子防火墙放行了该端口。只在本机用就保持 127.0.0.1,更安全。

7.3 流式中断 / 连接被掐

流式收到一半断了,常见原因:网络抖动(跨机时)、服务端因内存不足崩了、或客户端读超时设太短。对策:跨机优先保证网络稳定;客户端给足 timeout;服务端崩溃去查内存(cl / 并发是不是开大了)。

7.4 上下文超长报错

聊着聊着服务报错或回复异常,很可能是 messages 累积超过了 cl。对策:客户端做历史截断 / 滑动窗口 / 摘要(6.4、6.5),别指望服务端替你兜底。这条和第 7 讲"cl 是内存墙上的门"是同一个问题在服务化场景的重现。

7.5 模型路径 / 版本不对

服务起不来或输出乱码,回到老三样:模型路径是不是绝对路径、对话模板和模型是否匹配、服务/模型/QNN 版本是否对齐(4.2)。服务化只是加了网络层,底层这些坑一个都没少。

八、验证:curl + Python 多轮对话

8.1 curl 冒烟测试

不写代码,先用 curl 冒烟:

bash 复制代码
curl -s http://127.0.0.1:8888/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "qwen2.5-0.5b-instruct",
    "messages": [{"role": "user", "content": "你好,请自我介绍。"}]
  }'

能返回带 choices 的 JSON,说明服务链路通了。curl 是排查"是服务的问题还是我代码的问题"的最快手段------curl 通而代码不通,问题就在你的客户端代码。

8.2 首 token 延迟与吞吐(服务态)

服务化后,第 7 讲那两个指标依然要测,但多了一层网络。用流式请求计时:记录发出请求到收到第一个 token 的时间(首 token 延迟),以及整个生成耗时除以 token 数(吞吐)。本地回环的网络开销是毫秒级,所以这两个指标主要还是由模型尺寸、cl、并发决定。以真机实测为准。

8.3 多客户端并发

服务化的核心价值是"一个模型、多方调用",所以必须验证并发。开两三个 Python 进程同时调服务,观察:是否都能正常返回、响应是否变慢、内存是否暴涨、有没有排队或报错。这一步能帮你摸清端侧服务的真实并发承受力,为产品定义"同时支持几个客户端"提供依据。

8.4 异常对照表

服务化后出问题的现象,也能反推病因。一张速查表:"连接被拒绝" → 服务没起或端口不对(7.1、5.3);"本机通、跨机不通" → 绑定地址或防火墙(7.2、4.3);"聊着聊着报错" → 历史超 cl(7.4、6.4);"返回乱码/忽略指令" → 模型模板或版本问题(7.5、4.2);"流式半途断开" → 内存不足或网络/超时(7.3);"并发一上来就卡死" → 并发超内存承受(3.4、8.3)。先对号入座再翻对应章节,比盲改快得多。

8.5 服务健壮性:超时、重试与降级

把服务接进产品,就要假设它会偶尔出问题,客户端得有韧性。三条基本策略:一是超时 ,给每次请求设上限(比如首 token 等 30 秒、整体等 120 秒),别让一次卡死拖住整个业务;二是重试 ,对网络抖动类的失败做有限次重试并带退避,但对"模型真的崩了"别无限重试,那只会雪上加霜;三是降级,当本地服务持续不可用时产品要有兜底------提示用户稍后再试,或(在业务允许且合规的前提下)回退到云端接口。把这三条收进 6.5 的封装层,你的客户端就从"能调通"升级到"扛得住"。量产阶段的进程守护与崩溃自愈,第 12 讲会系统展开。

九、FAQ

Q1:AidGenSE 和 AidGen 是什么关系?

AidGen 是生成式推理框架(C++ 接口),AidGenSE 在它之上加了一个 OpenAI 兼容的 HTTP 服务层。要做"被程序调用的大模型服务"用 AidGenSE;要自己用 C++ 深度集成才直接碰 AidGen。

Q2:本地服务需要联网吗?

不需要。服务跑在板子上,客户端通过本机回环或局域网访问,全程数据不出设备。这正是端侧服务化对隐私、离线场景的意义。

Q3:一定要装 openai 这个 Python 包吗?

不一定。requests 直接发 HTTP 也能调(6.1、6.3)。装 openai 只是图它封装得好、且和你已有的 OpenAI 代码兼容。

Q4:api_key 要填什么?

本地服务通常不校验 key,给个占位字符串即可。如果文档说明需要鉴权,按文档配置。

Q5:能从外网访问这个服务吗?

技术上把端口暴露到公网可行,但强烈不建议------本地服务一般没有完善的鉴权与防护,暴露公网有风险。需要远程访问请走内网穿透或加一层带鉴权的网关,并做好安全配置。

Q6:多轮对话为什么要每次发完整历史?

因为 HTTP 服务无状态,服务端不记对话。上下文靠客户端每次把完整 messages 发上来维持。这也是为什么历史管理和 cl 控制落在调用方。

Q7:和直接用 llama.cpp 的 server 比呢?

思路一致(都是本地 OpenAI 兼容服务),差别在底层:AidGenSE 复用 AidGen/AidLite 的高通 NPU 调度与端侧优化。选它是为了和工具链其余部分(AidLite、AidGen)保持一致的后端与调度。

Q8:服务能同时挂多个模型吗?

取决于 AidGenSE 的能力与内存。端侧内存有限,挂多个大模型很容易爆。具体是否支持多模型、如何配置,以官方文档与真机实测为准。

Q9:首 token 很慢正常吗?

端侧小模型的首 token 延迟比云端高是常态,受模型尺寸、cl、并发影响。先看是否在你的体验预算内,再按第 7 讲的调优顺序(先尺寸、再 cl、再系统)压。

Q10:服务怎么开机自启 / 常驻?

把启动命令做成 systemd 服务或开机脚本即可,注意用绝对路径、配好依赖环境。量产化的进程守护、崩溃自愈等话题,第 12 讲会系统讲。

十、结论

这一讲,我们把第 7 讲的"本地大模型"真正变成了"可调用的服务":用 AidGenSE 在 AidGen 之上加了 OpenAI 兼容的 HTTP 门面,让 Qwen2.5 在犀牛派 X1 上以标准接口对外提供对话能力。你也亲手用 Python 的 requests 和 openai SDK 调通了它,包括流式接收和客户端侧的多轮历史管理。

更重要的是一个认知转变:服务化没有消除任何底层约束,反而把"会话状态、并发、上下文长度"这些责任更明确地交到了调用方手里。接口变成 HTTP 了,但模型尺寸、cl、内存这套端侧账,一笔都没少。理解这一点,你才不会以为"套了个 OpenAI 接口就成了云端"。

到这里,Linux 侧的能力已经很完整了------视觉(AidCV/AidStream)、推理(AidLite)、大模型(AidGen/AidGenSE)都在 Linux 这一边跑起来了。但回忆第 1 讲,这套融合系统的另一半是 Android:你的 App、你的界面、你的传感器,很可能在 Android 侧。Linux 上跑好的 AI 能力,怎么高效地喂给 Android 用?两边隔着系统边界,怎么低延迟地交换数据?下一讲我们就来解决这个"最后一公里"------用 AidConnect 打通 Android 与 Linux 之间的数据通道。

本文 AidGenSE 的定位、OpenAI 兼容接口、aidllm 用法、模型格式与端口等来自 AidLux 官方文档;并发上限、首 token 延迟与吞吐等指标参考端侧 LLM 的一般规律,精确值以真机实测与当前版本文档为准。文中命令、字段、代码为结构示意,具体以 AidGenSE SDK 与官方文档为准。

相关推荐
库拉大叔1 小时前
知漫剧分镜脚本制作教程:不用写提示词也能跑通
人工智能
辰哥单片机设计1 小时前
漏水传感器(STM32)
stm32·单片机·嵌入式硬件
飞猫的边缘AI1 小时前
边缘AI在自动驾驶应用中的行话汇总
人工智能·自动驾驶·芯片·模型·边缘ai·ai场景·城市noa
可乐ea2 小时前
多模型编排的三层:框架、模型路由与提供商路由
前端·网络·人工智能·ai智能体·多智能体协作·多模型编排·大模型路由
隔振降噪研究员2 小时前
振动筛振动治理科普
大数据·人工智能
恶魔泡泡糖2 小时前
stm32F103C8T6标准库串口接收之多串口基础应用5
stm32·单片机·嵌入式硬件
挖掘狂人2 小时前
从 "调模型" 到 "搭系统":Harness Engineering 凭什么成为 2026 年 AI 圈最热的新词
人工智能·agent·ai编程
段一凡-华北理工大学2 小时前
高炉炼铁机器视觉与智能识别十八讲~系列文章06:铁口与出铁场:出铁状态识别与渣铁分离判断
人工智能·数码相机·计算机视觉·高炉出铁识别·高炉渣铁分离·高炉铁口识别