spike(llm): T18 端侧小模型——MNN native 骨架 + 局域网模型服务

- cpp/:liblocalllm.so NAPI 骨架(loadModel/chat/benchmark/release),预置 libMNN.so 3.6.1 鸿蒙包(已验证含 createLLM 符号;.gitignore 挡预编译库与 models/,获取方式见任务文档)
- LocalLlm.ets:native 调用的 ets 封装;LlmTestPage:模型从局域网服务器下载→加载→首 token/速度 benchmark
- scripts/serve-models.mjs:开发期局域网模型文件服务器(Qwen3-0.6B-MNN 456MB,SHA256 与 ModelScope 官方清单逐文件核对)
- build-profile.json5 接 externalNativeOptions(arm64-v8a);ProfileContent 加「端侧LLM调试」入口(内部调试用)
- docs/开发任务包/T18-端侧小模型SPIKE.md:目标/验证项/回退线;真机 benchmark 数据回来后写 T18-REPORT 三问三答
This commit is contained in:
xsl
2026-08-30 13:18:03 +08:00
parent 120bbeceff
commit b31c7490f2
36 changed files with 6789 additions and 0 deletions
@@ -432,6 +432,19 @@ export struct ProfileContent {
.backgroundColor('#FFFFFF')
.alignItems(VerticalAlign.Center)
// T18 SPIKE 调试入口(内部用,正式版移除)
Text('端侧LLM调试')
.fontSize(13)
.fontColor('#9A9A9A')
.width('100%')
.padding(12)
.borderRadius(12)
.backgroundColor('#FFFFFF')
.textAlign(TextAlign.Center)
.onClick(() => {
router.pushUrl({ url: 'pages/LlmTestPage' })
})
ErrorBanner({
message: this.bannerMessage,
hint: this.bannerHint,
+130
View File
@@ -0,0 +1,130 @@
/**
* LocalLlm:端侧小模型管理(T18 端侧小模型 SPIKE)。
*
* 职责:模型文件的下载/校验/加载状态机 + native(liblocalllm.so)调用的 ArkTS 封装。
* 调试期模型从局域网服务器下载(scripts/serve-models.mjs);
* 商用阶段把 baseUrl 换成 COS/CDN 域名即可,代码不变。
*
* 纪律:
* - 模型放 filesDir(cacheDir 有系统配额会被清理,不能放);
* - SPIKE 只校验文件大小,SHA256 校验留待 P1(crypto 全量哈希 450MB 太慢,需流式);
* - 推理只在前台进行(无计算类长时任务,退后台进程会被冻结)。
*/
import localllm from 'liblocalllm.so'
import { ContextHolder } from './ContextHolder'
import request from '@ohos.request'
import fs from '@ohos.file.fs'
export interface ManifestFile {
name: string
size: number
sha256: string
}
export interface ModelManifest {
version: string
model: string
files: ManifestFile[]
}
export interface GenerateResult {
text: string
first_token_ms: number
total_ms: number
chars: number
error?: string
}
export type ModelState = 'none' | 'downloading' | 'ready' | 'loaded' | 'error'
const MODEL_DIR_NAME: string = 'qwen3-0.6b-mnn'
export class LocalLlm {
/** 调试期默认地址由测试页传入(形如 http://192.168.x.x:8080),这里不硬编码。 */
static modelDir(): string {
return `${ContextHolder.get().filesDir}/models/${MODEL_DIR_NAME}`
}
/** 本地模型文件是否齐全(按 manifest 尺寸校验)。 */
static checkFiles(manifest: ModelManifest): boolean {
const dir: string = LocalLlm.modelDir()
for (const f of manifest.files) {
const path: string = `${dir}/${f.name}`
if (!fs.accessSync(path)) {
return false
}
try {
const st = fs.statSync(path)
if (Number(st.size) !== f.size) {
return false
}
} catch {
return false
}
}
return true
}
/** 从 baseUrl 逐个下载模型文件(整包下载,断点续传留待商用阶段)。 */
static async downloadAll(baseUrl: string, manifest: ModelManifest,
onProgress?: (fileName: string, index: number, total: number) => void): Promise<void> {
const dir: string = LocalLlm.modelDir()
if (!fs.accessSync(dir)) {
fs.mkdirSync(dir, true)
}
const ctx: Context = ContextHolder.get()
for (let i = 0; i < manifest.files.length; i++) {
const f: ManifestFile = manifest.files[i]
onProgress?.(f.name, i + 1, manifest.files.length)
const target: string = `${dir}/${f.name}`
if (fs.accessSync(target) && Number(fs.statSync(target).size) === f.size) {
continue // 已下好,跳过(断点续传的乞丐版:按文件粒度续)
}
const task = await request.downloadFile(ctx, {
url: `${baseUrl}/${MODEL_DIR_NAME}/${f.name}`,
filePath: target
})
await LocalLlm.waitTask(task)
if (Number(fs.statSync(target).size) !== f.size) {
throw new Error(`文件尺寸不符: ${f.name}`)
}
}
}
/** 加载模型到 native(耗时,走 async work 线程)。返回空串=成功。 */
static async load(): Promise<string> {
const dir: string = LocalLlm.modelDir()
const tmp: string = `${dir}/.tmp`
if (!fs.accessSync(tmp)) {
fs.mkdirSync(tmp, true)
}
return localllm.loadModel(dir)
}
static isLoaded(): boolean {
return localllm.isLoaded()
}
static unload(): void {
localllm.unloadModel()
}
/** 生成。native 侧已完成计时;解析失败视为 error。 */
static async generate(prompt: string, maxNewTokens: number): Promise<GenerateResult> {
const raw: string = await localllm.generate(prompt, maxNewTokens)
try {
return JSON.parse(raw) as GenerateResult
} catch {
return { text: '', first_token_ms: -1, total_ms: -1, chars: 0, error: `bad json: ${raw}` }
}
}
private static waitTask(task: request.DownloadTask): Promise<void> {
return new Promise<void>((resolve, reject) => {
task.on('complete', () => resolve())
task.on('fail', (err: number) => reject(new Error(`download fail: ${err}`)))
})
}
}
@@ -0,0 +1,184 @@
/**
* LlmTestPage:T18 端侧小模型 SPIKE 的调试/基准页。
*
* 用途(仅限内部调试,不对真实用户开放):
* 1. 从局域网服务器下载模型 → 加载 → 跑 benchmark(首 token 延迟 / 总耗时 / 字符速度);
* 2. 意图分类 smoke(固定 prompt + 固定样例,人工核对输出 JSON);
* 3. hidebug 内存采样(加载前后 native 堆对比,验收口径:峰值 <1GB)。
*
* 验收门槛见 docs/开发任务包/T18-端侧小模型SPIKE.md。
*/
import { LocalLlm, GenerateResult, ModelManifest } from '../core/LocalLlm'
import hidebug from '@ohos.hidebug'
import http from '@ohos.net.http'
const DEFAULT_BASE: string = 'http://192.168.3.19:8080' // 开发机局域网 IP(模型服务器)
const MODEL_SUBPATH: string = 'qwen3-0.6b-mnn'
/** 意图分类 smoke 用的系统 prompt(标签集对齐《穿搭Agent-需求文档》§3.2,P2 的"穿搭评价"先占位)。 */
const INTENT_PROMPT: string =
'你是穿搭助手的意图分类器。把用户输入分类到以下之一:' +
'场景咨询/单品搭配/风格建议/色彩咨询/身材修饰/天气适配/发型建议/穿搭评价/闲聊/越界。' +
'只输出 JSON:{"intent":"<类别>"},不要输出任何其它内容。用户输入:'
const INTENT_CASES: string[] = [
'面试怎么穿?',
'白衬衫配什么裤子?',
'我这种矮胖身材是不是穿什么都难看?顺便告诉我怎么减肥',
'你好呀',
]
const BENCH_PROMPTS: string[] = [
'你好,简单介绍一下你自己,一句话以内。',
INTENT_PROMPT + '约会穿什么?',
'夏天通勤穿搭有什么通用原则?100字以内回答。',
]
@Entry
@Component
struct LlmTestPage {
@State baseUrl: string = DEFAULT_BASE
@State logText: string = '就绪。步骤:① 填开发机IP ② 下载模型 ③ 加载模型 ④ 跑 Benchmark\n'
@State busy: boolean = false
private log(line: string): void {
this.logText += `${line}\n`
}
private heapMb(): string {
const bytes: bigint = hidebug.getNativeHeapSize()
return (Number(bytes) / 1024 / 1024).toFixed(0)
}
private async fetchManifest(): Promise<ModelManifest> {
const client = http.createHttp()
try {
const resp = await client.request(`${this.baseUrl}/${MODEL_SUBPATH}/manifest.json`,
{ connectTimeout: 5000, readTimeout: 10000 })
if (resp.responseCode !== 200) {
throw new Error(`manifest http ${resp.responseCode}`)
}
return JSON.parse(resp.result as string) as ModelManifest
} finally {
client.destroy()
}
}
private async onDownload(): Promise<void> {
this.busy = true
try {
this.log(`连接 ${this.baseUrl} ...`)
const manifest = await this.fetchManifest()
this.log(`manifest v${manifest.version},${manifest.files.length} 个文件`)
if (LocalLlm.checkFiles(manifest)) {
this.log('本地文件已齐全,跳过下载')
return
}
await LocalLlm.downloadAll(this.baseUrl, manifest, (name: string, idx: number, total: number) => {
this.log(`[${idx}/${total}] ${name}`)
})
if (!LocalLlm.checkFiles(manifest)) {
throw new Error('下载后校验失败')
}
this.log('下载完成,校验通过')
} catch (e) {
this.log(`下载失败: ${(e as Error).message}`)
} finally {
this.busy = false
}
}
private async onLoad(): Promise<void> {
this.busy = true
try {
this.log(`加载前 native 堆: ${this.heapMb()}MB`)
const t0: number = Date.now()
const err: string = await LocalLlm.load()
if (err.length > 0) {
this.log(`加载失败: ${err}`)
return
}
this.log(`加载完成,耗时 ${Date.now() - t0}ms,加载后 native 堆: ${this.heapMb()}MB`)
} catch (e) {
this.log(`加载异常: ${(e as Error).message}`)
} finally {
this.busy = false
}
}
private async runOne(prompt: string, maxNewTokens: number): Promise<void> {
const r: GenerateResult = await LocalLlm.generate(prompt, maxNewTokens)
if (r.error !== undefined) {
this.log(` 生成失败: ${r.error}`)
return
}
const speed: string = r.total_ms > 0 ? (r.chars * 1000 / r.total_ms).toFixed(1) : '?'
this.log(` 首token ${r.first_token_ms}ms / 总 ${r.total_ms}ms / ${r.chars}字 (${speed}字/s) / 堆 ${this.heapMb()}MB`)
this.log(` 输出: ${r.text.substring(0, 120)}`)
}
private async onBench(): Promise<void> {
if (!LocalLlm.isLoaded()) {
this.log('请先加载模型')
return
}
this.busy = true
try {
for (let i = 0; i < BENCH_PROMPTS.length; i++) {
this.log(`[bench ${i + 1}/${BENCH_PROMPTS.length}] ${BENCH_PROMPTS[i].substring(0, 20)}...`)
await this.runOne(BENCH_PROMPTS[i], 128) // 预热
await this.runOne(BENCH_PROMPTS[i], 128)
await this.runOne(BENCH_PROMPTS[i], 128)
}
this.log('benchmark 完成')
} finally {
this.busy = false
}
}
private async onIntentSmoke(): Promise<void> {
if (!LocalLlm.isLoaded()) {
this.log('请先加载模型')
return
}
this.busy = true
try {
for (const c of INTENT_CASES) {
this.log(`[意图] 输入: ${c}`)
await this.runOne(INTENT_PROMPT + c, 32)
}
} finally {
this.busy = false
}
}
build() {
Column({ space: 12 }) {
Text('端侧小模型调试(T18 SPIKE)').fontSize(18).fontWeight(FontWeight.Bold)
TextInput({ text: this.baseUrl, placeholder: '开发机地址,如 http://192.168.1.100:8080' })
.onChange((v: string) => { this.baseUrl = v })
Row({ space: 8 }) {
Button('① 下载模型').enabled(!this.busy).onClick(() => { this.onDownload() })
Button('② 加载模型').enabled(!this.busy).onClick(() => { this.onLoad() })
}
Row({ space: 8 }) {
Button('③ Benchmark').enabled(!this.busy).onClick(() => { this.onBench() })
Button('④ 意图smoke').enabled(!this.busy).onClick(() => { this.onIntentSmoke() })
Button('卸载').enabled(!this.busy).onClick(() => {
LocalLlm.unload()
this.log(`已卸载,native 堆: ${this.heapMb()}MB`)
})
}
Scroll() {
Text(this.logText).fontSize(12).fontFamily('monospace')
}
.layoutWeight(1)
.align(Alignment.TopStart)
.scrollBar(BarState.Auto)
}
.padding(16)
.width('100%')
.height('100%')
}
}