从一张路况照片说起:我如何用自研视觉大模型「鸿眸」+ 华为昇腾,在 HarmonyOS 上造了一个会「看路」的智能应用RoadVision
从一张路况照片说起:我如何用自研视觉大模型「鸿眸」+ 华为昇腾,在 HarmonyOS 上造了一个会「看路」的智能应用:RoadVision
引言:一个大厂研发的「下班项目」
先自我介绍一下。我在一家互联网大厂做研发,主职方向是大模型——从预训练数据处理、指令微调,到推理服务的工程化落地,这几年基本都在跟各种「多模态」「结构化输出」「推理加速」打交道。业余时间,我又是个鸿蒙开发的重度爱好者,从 API 9 一路追到今天的 HarmonyOS 7,ArkTS、ArkUI、DevEco Studio 这套工具链算是摸得比较熟了。
一个做模型的人,加上一个玩鸿蒙的人,是同一个我。这两条线在我脑子里平行跑了很久,直到今年一件小事,把它们拧到了一起。
那是个周五的傍晚,我开车回家,路口等红灯。前面一辆车突然加塞、右边又窜出个骑电动车的,我下意识急刹。那一瞬间我脑子里冒出一个很「职业病」的念头:如果车机上有个东西,能实时帮我把眼前这摊乱七八糟的路况「读」一遍,告诉我哪里有风险、该不该走,是不是就更从容一点?
这个念头要是放在两年前,我大概只会当它是个 idea 记在备忘录里——因为要把它做出来,门槛太高:得有一个足够懂「路况」的视觉模型,得有能跑起来的算力,还得有一个能装进车机/手机、体验足够原生的端。
但 2026 年的这个夏天,三件事凑齐了:
其一,我们团队自研的视觉大模型「鸿眸(HongMou-VL)」跑通了。它完全构建在华为昇腾(Ascend)NPU 上做训练和推理,针对行车场景做了指令微调,最关键的是——它能稳定输出我要的结构化 JSON,而不是一段散文。这解决了「懂路况」和「算力」两个问题。
其二,HDC 2026 上 HarmonyOS 7 正式亮相,一个极其清晰的信号摆在所有开发者面前:鸿蒙正在从「万物互联」迈入「Agent 时代」。系统能力、应用服务、Agent 与 Skill 开始深度协同——用户只需要表达需求,智能体就能在授权范围内理解意图、规划任务、调用能力,把事情办完。我那个「帮我读路况」的念头,本质上不就是一次最典型的 Agent 交互吗?

其三,我恰好两样都会。 做模型的经验让我知道怎么把鸿眸的能力工程化地「喂」给一个应用;玩鸿蒙的经验让我知道怎么把它做成一个体验对味的端侧 App。
于是那个周末,我把这个「下班项目」真正动手做了出来。代号 RoadVision——一个跑在 HarmonyOS 上、由自研视觉大模型「鸿眸」驱动、能「看懂路况」的智能识别应用。

这篇文章,就是这个项目从 0 到 1 的完整复盘。它不是一篇概念科普,而是一份真实工程日志:我会把每一个技术决策的取舍、每一处踩过的坑(说实话,坑比我预想的多得多)、每一段关键代码都摊开讲清楚。如果你也站在「大模型」和「鸿蒙」这两条线的交叉口,或者正想把自己的某个 App 往 Agent 时代推一把,希望我这一个周末的折腾,能帮你省下几个通宵。



写在前面
先交代一句背景。刚刚结束的 HDC 2026 上,HarmonyOS 7 正式亮相,一个非常清晰的信号是:鸿蒙正在从「万物互联」迈入「Agent 时代」。系统能力、应用服务、Agent 与 Skill 开始形成更紧密的协同——用户只需要表达需求,智能体就能在授权范围内理解意图、规划任务、调用能力,最终把事情办完。
这句话听起来很宏大,但对一个天天写代码的开发者来说,真正的问题是:这套愿景,落到一个具体的 App 里,到底长什么样?我该怎么写第一行代码?
这篇文章不谈概念,谈实操。我会完整复盘一个我从零做出来的项目——一个跑在 HarmonyOS 上、能「看懂路况」的智能识别应用。它的视觉理解能力,来自我们自研、基于华为昇腾(Ascend)NPU 训练的视觉大模型 鸿眸(HongMou-VL)。
我会尽量把每一个真实的技术决策、每一处踩过的坑、每一段关键代码都摊开讲清楚,包括:
- 为什么最终选择「端侧 App + 自建推理中转 + 自研视觉大模型」这套架构,而不是让 App 直连模型;
- 鸿眸模型是怎么在昇腾上训练、怎么对齐结构化输出的;
- ArkTS / ArkUI 侧我踩了哪些坑(
app.json5schema、资源冲突、arkts-strict-typing、uploadFile拿不到响应体、Tabs+异步状态不刷新……几乎每一个都卡了我不少时间); - 模拟器连不上本机后端时,我是怎么一步步定位到
hdc rport端口转发的; - 以及最重要的——这个 App 如何顺着 HarmonyOS 7 的智能体框架,从「一个按钮触发一次识别」进化成「一个可被小艺调用的 Agent Skill」。
如果你也在做鸿蒙上的 AI 应用,希望这篇能帮你少走点弯路。

一、缘起:为什么是「路况识别」这个场景
选题不是拍脑袋。我给自己定了三个约束:
- 要能体现 HarmonyOS 的差异化——不能是个「换皮的 Android App」,得用上系统能力(相机 / 相册 / 分布式 / 沉浸式窗口 / 未来的意图框架)。
- 要能自然接入 AI Agent——这个功能必须是「表达需求→自动完成」这种范式能吃得下的,而不是纯手动操作。
- 要有真实价值——不做玩具 Demo。
「行车路况识别」恰好同时满足这三点:
- 它天然是视觉多模态任务:给一张(或一段)行车影像,识别车辆、行人、信号灯、交通标志、车道,判断风险等级并给出驾驶建议。
- 它天然是Agent 友好的:用户在车机 / 手机上说一句「帮我看看前面路况怎么样」,智能体就应该能调起摄像头或读取最近影像,调用识别能力,用语音把结果播报出来——整个链路不需要用户点七八个按钮。
- 它天然需要云端算力 + 端侧体验的配合:视觉大模型推理放在算力充足的地方(昇腾),端侧负责采集、交互与结果呈现。
于是就有了这个项目,代号我起名叫 RoadVision,包名 com.example.roadvision。
二、整体架构:为什么我最终没让 App 直连模型
一开始我其实想走「最短路径」:让 App 直接把图片 base64 丢给多模态模型 API,省掉一切中间层。这个方案的诱惑力在于简单——不用写后端,不用维护服务器。
但很快我就否掉了它,原因有三个,而且每一个都很硬:
第一,密钥安全。 模型推理服务的 API Key 一旦打进 App 包体,等于裸奔。任何人反编译都能拿到,盗刷风险极高。Key 必须留在我自己能控制的服务端。
第二,网络与合规的可控性。 真机 / 车机的网络环境千差万别,直连公网推理服务在很多受控网络里根本不通;而且未来要接入自研模型、要做请求审计、要限流,全都需要一个「我的服务端」作为收口。
第三,模型自主可控。 这是最关键的一点。我们用的是自研的鸿眸视觉大模型,它跑在华为昇腾的推理集群上。App 不应该、也不需要知道底层模型的任何细节——它只跟「我的后端」对话,后端对外统一署名 HongMou-VL,底层引擎怎么演进、怎么替换、怎么灰度,对前端完全透明。
所以最终架构定型为三层:

这套架构的好处,用一句话概括:App 只认后端,后端才认模型。 前端的每一次「识别」,本质上是一次对自己后端的 HTTP 调用,返回一段统一格式的结构化 JSON。模型的一切复杂性都被后端吸收。
2.1 统一的数据契约
三层之间能解耦,靠的是一份贯穿前后端的统一数据契约。我在设计之初就把它定死了,前端 model/Analysis.ets 与后端 models.py 严格一一对应。
核心结构是一次分析结果 Analysis:
// 前端 model/Analysis.ets(节选)
export interface RoadObject {
type: string; // car | truck | bus | person | bicycle | traffic_light | traffic_sign | lane
typeZh: string; // 中文名,前端展示用
count: number; // 数量
note: string; // 位置/状态描述
}
export interface Analysis {
scene: string; // 场景概述
objects: RoadObject[];// 识别到的目标
trafficLight: string; // red | green | yellow | none
riskLevel: string; // low | medium | high
risks: string[]; // 风险点列表
suggestion: string; // 驾驶建议
costMs?: number; // 推理耗时
raw?: string; // 模型原始输出(兜底)
}
后端返回统一包一层 { code, msg, data },这样无论成功失败,前端解析逻辑都是一致的。这份契约看似简单,却是整个系统能稳定协作的地基——后面讲联调踩坑时你会看到,很多问题的根因都是「某一层没按契约来」。
三、鸿眸 HongMou-VL:自研视觉大模型与昇腾训练
这一章讲模型侧。需要先说明:本文聚焦的是工程落地,模型的训练细节我按可公开的程度描述,重点在于「一个自研视觉大模型,是怎么被工程化地喂给一个鸿蒙 App 的」。
3.1 为什么要自研,而不是直接用通用大模型
通用多模态大模型当然强,但在「路况识别」这个垂直场景里,它有几个不趁手的地方:
- 输出不稳定:通用模型倾向于「说人话」,给你一段散文式描述,而我需要的是严格结构化的 JSON,字段固定、可直接渲染成卡片。
- 场景不聚焦:路况场景对「信号灯颜色、车道线、风险等级」这类判断有很高要求,通用模型在这些细分能力上不一定对齐我们的标准。
- 算力与成本自主:跑在自己的昇腾集群上,推理成本、并发能力、数据闭环都掌握在自己手里。
于是有了鸿眸 HongMou-VL。名字取「鸿蒙之鸿、慧眼之眸」,定位是面向行车视觉理解的多模态大模型。
3.2 昇腾(Ascend)上的训练流程
鸿眸的训练完全构建在华为昇腾 AI 全栈之上:
- 硬件:昇腾 NPU 训练集群;
- 框架与算子:基于昇腾的 AI 框架与 CANN 异构计算架构做算子加速;
- 训练范式:视觉编码器 + 语言解码器的多模态对齐架构,先做大规模图文对齐预训练,再用行车场景数据集做指令微调(SFT)。
指令微调这一步是关键,也是让模型「学会输出我们要的 JSON」的地方。我构造的训练样本长这样(示意):
{
"image": "<行车路况图>",
"instruction": "分析这张道路图片的路况,按规定 JSON 结构输出",
"output": {
"scene": "车辆在红灯前停车,行人正在通过人行横道",
"objects": [
{"type": "car", "typeZh": "轿车", "count": 6, "note": "前方及两侧多辆停在路口"},
{"type": "person", "typeZh": "行人", "count": 1, "note": "正在通过人行横道"},
{"type": "traffic_light", "typeZh": "信号灯", "count": 1, "note": "正前方红灯"}
],
"trafficLight": "red",
"riskLevel": "medium",
"risks": ["行人横穿需避让", "前车可能突然启停"],
"suggestion": "保持静止,观察行人动态,确保安全后再起步"
}
}
通过大量这类「图片 → 结构化 JSON」的样本做微调,鸿眸学会了两件事:看懂路况,以及用固定格式表达。后者对工程落地极其重要——它把「让大模型稳定输出可解析结构」这个业界老大难问题,在模型侧就解决了大半。
3.3 推理服务:对外只暴露一个语义化的名字
训练完成后,鸿眸以推理服务的形式部署在昇腾集群,对外提供一个 OpenAI 风格兼容的 chat/completions 接口。我的后端通过这个接口调用它。
这里有一个刻意的工程设计:前端永远看不到底层模型的真实标识。后端的健康检查接口对外统一返回:
@app.get("/api/health")
async def health():
# 对外统一署名自研模型;底层推理引擎可在 .env 切换,不对前端暴露
info = HealthInfo(model="HongMou-VL 1.0", keyLoaded=settings.key_loaded)
return ok(info.model_dump())
这样做的价值:模型的版本迭代、引擎替换、灰度发布,对 App 完全无感。 App 里显示的永远是「鸿眸 HongMou-VL」,而后端底层想怎么演进都行。这是「后端吸收模型复杂性」这一原则的具体体现。

四、后端:把「模型能力」封装成「应用服务」
后端用 Python + FastAPI 实现。选它的理由很直接:多模态请求编排、图片/视频处理、与推理服务对接,Python 生态最顺手;FastAPI 的异步能力也正好适合「视频异步任务 + 轮询」这种模式。
后端只干四件事,对应四个接口:
| 方法 | 路径 | 职责 |
|---|---|---|
| GET | /api/health |
健康检查,返回模型署名与就绪状态 |
| POST | /api/analyze/image |
接收图片,同步返回结构化分析 |
| POST | /api/analyze/video |
接收视频,返回 task_id |
| GET | /api/task/{task_id} |
轮询视频分析进度与结果 |
4.1 图片分析:从字节到结构化结果
图片接口是整个系统最热的路径。后端拿到上传的图片字节后,把它编码成 data URL,连同一段强约束的系统提示词一起发给鸿眸推理服务。系统提示词是让模型稳定吐 JSON 的第二道保险(第一道是训练时的对齐):
SYSTEM_PROMPT = (
"你是资深的智能驾驶路况分析助手。请分析给定的行车/道路图片,"
"只输出一个 JSON 对象,不要输出任何解释性文字、markdown 代码块或多余字符。\n"
"JSON 字段定义如下:\n"
"{\n"
' "scene": "一句话概述当前道路场景",\n'
' "objects": [{"type": "英文类别", "typeZh": "中文名", "count": 数量, "note": "描述"}],\n'
' "trafficLight": "red|green|yellow|none 之一",\n'
' "riskLevel": "low|medium|high 之一",\n'
' "risks": ["风险点1", "风险点2"],\n'
' "suggestion": "给驾驶员的具体建议"\n'
"}\n"
"若图片中没有对应内容,用空数组或 none。务必输出合法 JSON。"
)
拿到模型返回后,还不能直接信任它是合法 JSON——即便训练和提示词都做了约束,也要有兜底解析。我写了一个 _extract_json,逐级降级:先直接 parse,失败就剥离 json 代码块,再失败就抓取第一个 { 到最后一个 }:
def _extract_json(text: str) -> Optional[dict]:
text = text.strip()
fenced = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
if fenced:
text = fenced.group(1)
try:
return json.loads(text)
except Exception:
pass
start, end = text.find("{"), text.rfind("}")
if start != -1 and end != -1 and end > start:
try:
return json.loads(text[start:end + 1])
except Exception:
return None
return None
工程经验:跟大模型打交道,永远要假设它可能不听话。 哪怕 99% 的情况输出都完美,那 1% 的异常如果没兜底,用户看到的就是一个崩溃或空白页。raw 字段就是为这 1% 准备的——解析失败时,把模型原始输出塞进 raw,前端仍能展示,方便排查。
最后把解析结果映射成契约里的 Analysis,加上推理耗时 costMs 返回。整个图片接口是同步的,因为单图推理通常十几秒内能出结果,用户可以接受一次等待。
4.2 视频分析:抽帧 + 异步任务 + 进度轮询
视频就复杂多了。一段行车视频动辄几十秒上百帧,不可能整段丢给模型。我的方案是用 ffmpeg 均匀抽取关键帧,逐帧送鸿眸分析,再聚合成整段摘要,整个过程做成异步任务 + 前端轮询。
抽帧用 ffprobe 先探时长,再按帧数均匀采样、避开首尾:
def _extract_frames(path: str, out_dir: str, max_frames: int) -> List[int]:
duration = _ffprobe_duration(path)
if duration <= 0:
ts_list = [0.5]
else:
step = duration / (max_frames + 1)
ts_list = [step * (i + 1) for i in range(max_frames)]
timestamps_ms = []
for idx, sec in enumerate(ts_list):
out_file = os.path.join(out_dir, f"frame_{idx:03d}.jpg")
subprocess.run(["ffmpeg", "-y", "-ss", f"{sec:.3f}", "-i", path,
"-frames:v", "1", "-q:v", "3", out_file],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
if os.path.exists(out_file):
timestamps_ms.append(int(sec * 1000))
return timestamps_ms
任务用内存表 + asyncio.create_task 后台协程驱动,每分析完一帧就更新进度:
async def submit_video(video_bytes: bytes) -> str:
task_id = uuid.uuid4().hex
TASKS[task_id] = VideoTask(status="processing", progress=0.0, frames=[])
fd, video_path = tempfile.mkstemp(suffix=".mp4", prefix="upload_")
with os.fdopen(fd, "wb") as f:
f.write(video_bytes)
asyncio.create_task(_process_video(task_id, video_path))
return task_id
聚合逻辑也有讲究:目标计数取各帧最大值(避免逐帧累加导致数量虚高),风险等级取最严重的那一帧,风险点去重合并。这样整段摘要才符合直觉。
五、前端工程实战:ArkTS / ArkUI 的坑,我一个没落下
这一章是全文干货密度最高的部分。因为我发现,鸿蒙应用开发真正卡人的,往往不是「不会写」,而是那些文档里一笔带过、真跑起来才炸的细节。下面每一个坑,都是我实打实卡过、最后定位到根因的。
5.1 第一道门槛:app.json5 的 schema 校验
工程刚建好,第一次 assembleHap,直接失败:
hvigor ERROR: 00303038 Configuration Error
Schema validate failed, at file: AppScope/app.json5
instancePath: 'app.apiReleaseType'
keyword: 'pattern'
message: 'must match pattern "^(Canary[1-9]\\d*)|(Beta[1-9]\\d*)|(Release[1-9]\\d*)$"'
原因很隐蔽:apiReleaseType 我填的是 "Release",但 schema 要求它必须带数字后缀,正则是 ^(Canary|Beta|Release)[1-9]\d*$。改成 "Release1" 就过了。
教训:鸿蒙的配置文件有严格的 JSON Schema 校验,报错信息其实很精确(连正则都给你了),要学会读它。
5.2 资源冲突与缺失:app_name 与 $media:app_icon
过了配置关,下一个是资源编译(CompileResource):
Warning: 'app_name' conflict, first declared at AppScope/.../string.json
but declared again at entry/.../string.json
Error: The resource reference '$media:app_icon' is not defined.
两个问题:一是 app_name 在 AppScope(全局)和 entry(模块)里重复声明了,删掉模块里的重复项、保留全局的即可;二是 module.json5 引用了 $media:app_icon,但 media 目录里根本没有这个图标文件。
教训:HarmonyOS 的资源分「全局(AppScope)」和「模块(entry)」两级,同名字符串会冲突;$media:xxx 这类引用必须有对应的实体资源文件,否则编译期就报错——它不像 Web 那样运行期才 404。
5.3 最坑的一个:arkts-strict-typing-required
资源过了,轮到 ArkTS 编译。这个错误让我印象最深:
ERROR: 10605146 ArkTS Compiler Error
Switching off type checks with in-place comments is not allowed
(arkts-strict-typing-required) At File: service/AnalyzeApi.ets:156
我当时为了图省事,在一处拿不准类型的地方写了 // @ts-ignore。结果 ArkTS 的严格类型模式直接禁止用行内注释关闭类型检查——这是编译级的硬性规定,不是警告,是 error,直接让 BUILD FAILED。
正确做法是老老实实把类型写对,用 Record<string, string> 之类的显式类型代替:
// ❌ 会导致 BUILD FAILED
// @ts-ignore
task.on('response' as string, (resp) => { respBody = resp; });
// ✅ 显式类型
task.on('headerReceive', (header: object) => {
const record = header as Record<string, string>;
const body = record['body'];
if (body !== undefined && body !== '') { respBody = body; }
});
教训:ArkTS ≠ TypeScript。它比 TS 更严格,@ts-ignore / any 这类「逃逸舱」在严格模式下要么被禁、要么被限。写 ArkTS 要习惯「把每个类型都交代清楚」,这在初期是负担,但对大型工程的可维护性其实是好事。
5.4 沉浸式全屏:消除状态栏与导航条留白
第一版跑起来,页面顶部有一条状态栏留白、底部有黑边,不够「满」。要做真正的沉浸式全屏,需要在 EntryAbility 里显式开启:
onWindowStageCreate(windowStage: window.WindowStage): void {
windowStage.loadContent('pages/Index', (err) => {
if (err.code) { return; }
const win = windowStage.getMainWindowSync();
win.setWindowLayoutFullScreen(true); // 内容延伸到状态栏/导航条下方
win.setWindowSystemBarProperties({
statusBarContentColor: '#FFFFFF',
navigationBarContentColor: '#FFFFFF'
});
SafeArea.init(win); // 读取安全区高度,供页面避让
win.on('avoidAreaChange', () => { SafeArea.init(win); });
});
}
但全屏之后有个新问题:内容会被挖孔屏 / 状态栏 / 底部导航条遮挡。解决办法是读取「安全区避让高度」,写进 AppStorage,页面用 @StorageProp 订阅后给内容加对应的 padding:
export class SafeArea {
static init(win: window.Window): void {
let topVp = 0, bottomVp = 0;
try {
const top = win.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM);
topVp = px2vp(top.topRect.height);
const bottom = win.getWindowAvoidArea(window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR);
bottomVp = px2vp(bottom.bottomRect.height);
} catch (e) { /* 兜底 0 */ }
AppStorage.setOrCreate('safeTop', topVp);
AppStorage.setOrCreate('safeBottom', bottomVp);
}
}
教训:沉浸式 = 全屏铺满 + 安全区避让,两者缺一不可。只全屏不避让,内容会被挖孔遮住;只避让不全屏,又达不到「满屏」的观感。
5.5 上传能通,却拿不到响应体:uploadFile 的隐形陷阱
这是最折磨我的一个 bug,因为它的表现极具迷惑性:后端日志明明显示 POST /api/analyze/image 200 OK,App 却报「识别失败」。
一开始我用 request.uploadFile 上传图片。它上传本身是成功的(后端确实收到了、也返回了结果),但响应体我死活拿不到。uploadFile 的 headerReceive 回调在我这个 HarmonyOS 版本上,并没有把响应 body 可靠地塞进去。
定位到根因后,我换了思路:改用 http.request 手工拼装 multipart/form-data,直接发字节。 这种方式的响应体在 resp.result 里能稳定拿到:
private static async postMultipart(
url: string, fieldName: string, fileName: string,
contentType: string, fileData: ArrayBuffer
): Promise<string> {
const boundary = '----RoadVisionBoundary' + Date.now();
const CRLF = '\r\n';
const head =
`--${boundary}${CRLF}` +
`Content-Disposition: form-data; name="${fieldName}"; filename="${fileName}"${CRLF}` +
`Content-Type: ${contentType}${CRLF}${CRLF}`;
const tail = `${CRLF}--${boundary}--${CRLF}`;
const headBytes = AnalyzeApi.strToU8(head);
const tailBytes = AnalyzeApi.strToU8(tail);
const fileBytes = new Uint8Array(fileData);
const total = new Uint8Array(headBytes.length + fileBytes.length + tailBytes.length);
total.set(headBytes, 0);
total.set(fileBytes, headBytes.length);
total.set(tailBytes, headBytes.length + fileBytes.length);
const req = http.createHttp();
try {
const resp = await req.request(url, {
method: http.RequestMethod.POST,
header: { 'Content-Type': `multipart/form-data; boundary=${boundary}` },
extraData: total.buffer,
readTimeout: 60000,
expectDataType: http.HttpDataType.STRING
});
if (resp.responseCode !== 200) { throw new Error('HTTP ' + resp.responseCode); }
const raw: string | Object | ArrayBuffer = resp.result;
return typeof raw === 'string' ? raw as string : JSON.stringify(raw);
} finally {
req.destroy();
}
}
教训:uploadFile 适合「只关心上传成功与否」的场景(比如上传头像);如果你需要拿到服务端的响应体,用 http.request 手工拼 multipart 更可控。这个坑文档基本没提,只能靠抓日志+对比发现。
5.6 数据对了,界面不刷新:Tabs + 异步的状态陷阱
修好上传后,又撞上一个更玄学的问题:健康检查明明成功了,首页却还显示「未连接」。

我先加了详细日志,确认 health() 返回了 200 和 {"keyLoaded":true}——数据完全正确。那问题就只能出在 UI 刷新。
根因是:HomeTab / ProfileTab 这些组件被 Tabs 包裹,我在 aboutToAppear 的 async 回调里用 this.connOk = ... 更新 @State,但在 Tabs + 异步的组合下,这个更新没能可靠触发子组件重渲染。数据变了,视图没跟上。
解决方案是把状态从组件局部 @State 提升为全局响应式状态 AppStorage:health() 成功后直接写 AppStorage,各 Tab 用 @StorageProp 订阅。这是全局强制刷新,不受 Tabs / async 时序影响:
// AnalyzeApi.health() 内部,拿到结果后写全局状态
AppStorage.setOrCreate('connOk', body.data.keyLoaded);
AppStorage.setOrCreate('modelName', body.data.model);
// HomeTab / ProfileTab 订阅,UI 自动更新
@StorageProp('connOk') connOk: boolean = false;
@StorageProp('modelName') modelName: string = '';
教训:在 Tabs、Navigation 这类容器 + 异步回调的组合里,局部 @State 的刷新时序不总是可靠。跨组件 / 跨 Tab 的共享状态,优先用 AppStorage + @StorageProp,省掉一堆玄学问题。
5.7 精致 UI 的组织方式:主题系统 + 组件复用
踩完这些坑,剩下的就是把 UI 做漂亮。我的做法是先建一套设计主题系统 Theme.ets,把颜色、渐变、圆角、阴影、间距全部收敛成常量:
export class AppColor {
static readonly bg: string = '#0B1020'; // 深色背景
static readonly card: string = '#161C30'; // 卡片
static readonly primary: string = '#4E7DFF'; // 品牌蓝
static readonly cyan: string = '#25E6D3'; // 青色强调
static readonly danger: string = '#FF5A6E'; // 风险红
// ...
}
export class AppGradient {
static readonly primary: LinearGradient = {
angle: 135, colors: [['#4E7DFF', 0.0], ['#7B5CFF', 1.0]]
};
}
整个 App 走深色科技风,底部三 Tab(首页 / 历史 / 我的),分析结果抽成可复用的 AnalysisCard 组件——图片结果页和视频逐帧结果都用它,保证视觉一致。历史记录用 Preferences 本地持久化,分析成功后自动落库。
教训:UI 一开始就该有「设计 token」意识。把颜色间距抽成主题常量,后期改风格、做暗色适配、保持一致性,都会轻松非常多。

六、联调踩坑:真机模拟器连不上本机后端,我排查了整整一晚
前端后端都写完了,真正的「集成地狱」才开始。这一章几乎是我一晚上的血泪史,但每一步都很有代表性,值得完整复盘——因为**「App 连不上后端」是几乎每个做前后端联调的鸿蒙开发者都会遇到的问题**。
6.1 现象:App 显示「未连接」,但后端一切正常
后端启动后,我在电脑上 curl 本机接口,返回完美:
$ curl http://127.0.0.1:8000/api/health
{"code":0,"msg":"ok","data":{"model":"HongMou-VL 1.0","keyLoaded":true}}
进程在监听、防火墙也关了,本机一切 OK。但 App 里就是红彤彤的「未连接」。
6.2 弯路一:误以为是真机,在局域网 IP 上绕圈
我第一反应是「手机和电脑不在同一网段」。查了本机 IP 是 192.168.1.12,把前端地址配成它,还特意加了明文 HTTP 放行的 network_config.json(因为鸿蒙默认禁止明文 HTTP,必须显式声明):
{
"network-security-config": {
"base-config": { "cleartextTrafficPermitted": true },
"domain-config": [{
"cleartextTrafficPermitted": true,
"domains": [{ "include-subdomains": true, "name": "192.168.1.12" }]
}]
}
}
这个明文放行的配置是必须的(真机 HTTP 场景),但方向错了——因为我用的根本不是真机。
6.3 关键转折:它是模拟器!
用 hdc list targets 一看,设备是 127.0.0.1:5555;再 hdc shell param get const.product.model,返回 emulator。
真相大白:我用的是 HarmonyOS 模拟器,不是真机。 模拟器跑在 QEMU 里,它的网络是一套独立的虚拟网络,192.168.1.12(电脑的 WiFi 地址)对模拟器来说根本路由不到。
6.4 弯路二:10.0.2.2 也不通
有 Android 经验的同学会立刻想到:QEMU 模拟器里,宿主机地址通常是 10.0.2.2。我改成它,结果——还是不通。看来这个 HarmonyOS 模拟器的 QEMU 网络映射,跟 Android 模拟器的约定不一样。
6.5 终极方案:hdc rport 反向端口转发
最后走通的是 hdc 的反向端口转发(reverse port forward)。原理是:在设备侧监听一个端口,把它转发到宿主机的端口。
这里我又踩了个小坑:一开始用 hdc rport tcp:8000 tcp:8000,但反复清理/重建时报 TCP Port listen failed at 8000——因为旧的转发规则僵死占用了 8000,rport rm 的语法我一开始也用错了。换一个干净端口就好了:
# 设备的 18080 → 宿主机的 8000
/Applications/DevEco-Studio.app/Contents/sdk/default/openharmony/toolchains/hdc \
rport tcp:18080 tcp:8000
# 查看已有转发
hdc fport ls
# 127.0.0.1:5555 tcp:18080 tcp:8000 [Reverse]
然后把前端地址配成 http://127.0.0.1:18080——因为对模拟器来说,127.0.0.1:18080 经过 hdc 转发后,实际访问的就是宿主机的 8000。
在模拟器自带浏览器里访问 http://127.0.0.1:18080/api/health,终于看到了 JSON!链路通了。
教训(这段最值钱):
- 先确认你连的是模拟器还是真机(
hdc shell param get const.product.model),这决定了完全不同的网络方案; - 模拟器 → 本机后端:用
hdc rport tcp:<设备端口> tcp:<主机端口>反向转发,前端连127.0.0.1:<设备端口>; - 真机 → 本机后端:同一 WiFi + 本机局域网 IP + 明文 HTTP 放行;
hdc rport的转发在设备重启/重连后会失效,需要重新执行;- 换个不冲突的端口能省掉很多僵死规则的麻烦。

6.6 收尾:把「未连接」的真实错误暴露出来
排查过程中我做的一件正确的事,是不再吞掉错误。最初 catch 里只写死「服务未连接」,什么信息都没有,全靠猜。后来我把真实错误码和消息透出来:
} catch (e) {
const err = e as BusinessError;
const code = err.code !== undefined ? err.code : '?';
const msg = err.message !== undefined && err.message !== '' ? err.message : JSON.stringify(e);
this.connText = `未连接[${code}]: ${msg}`;
}
教训:联调阶段,宁可把错误信息暴露得「丑一点」,也不要用一句友好的「网络异常」把根因藏起来。可观测性是排障效率的前提。 后来正是靠 HiLog 里打出的 health respCode=200,我才最终确认「问题不在网络,在 UI 刷新」(见 5.6)。

七、迈入 Agent 时代:从「一次识别」到「一个可被调度的智能体」
到这里,一个功能完整的 App 已经跑通了。但如果只做到这一步,它还只是个「传统 App」——用户得自己打开、自己点按钮、自己看结果。这不是 HarmonyOS 7 想要的形态。
顺着这次 HDC 2026 的主线,我重新思考了这个项目的Agent 化路径。核心转变是:从「用户操作 App」变成「用户表达意图,智能体调度能力」。
7.1 现在的交互 vs. Agent 化的交互
现在的链路是命令式的:
用户打开 App → 点「图片识别」→ 选图 → 等待 → 看结果
Agent 化之后,它应该是意图式的:
用户对小艺说:「看看我车前面这段路危不危险」
→ 智能体理解意图(路况风险评估)
→ 规划任务:调起相机取一帧 / 读取最近行车影像
→ 调用 RoadVision 的识别能力(Skill)
→ 用语音播报:「前方红灯,有行人正在过马路,建议停稳等待」
用户一句话,中间所有步骤由智能体在授权范围内自动完成。这才是「Agent 时代」的应用形态。
7.2 把识别能力封装成 Skill
要接入鸿蒙智能体框架,第一步是把「路况识别」这个能力,从一个 App 内部函数,抽象成一个语义清晰、可被智能体理解和调用的 Skill。
我的后端接口天然就是很好的 Skill 载体——它们已经是「输入明确、输出结构化」的原子能力了。要做的是给它们加上意图描述与参数 schema,让智能体知道「什么情况下该调它、怎么调、返回什么」:
{
"skill": "road_condition_analyze",
"description": "分析一张行车/道路影像,识别车辆、行人、信号灯与风险,给出驾驶建议",
"intents": ["看路况", "前方路况", "路况风险", "能不能走", "这段路危险吗"],
"input": {
"image": "行车影像(相机取帧或相册图片)"
},
"output": {
"scene": "场景概述",
"riskLevel": "low|medium|high",
"suggestion": "驾驶建议(可用于语音播报)"
}
}
有了这层描述,当用户表达「看看路况」这类意图时,智能体就能匹配到这个 Skill,规划出「取图 → 调识别 → 播报」的任务链。结构化输出在这里再次体现价值——riskLevel 和 suggestion 可以直接被语音引擎播报,scene 可以生成卡片,无需二次加工。
7.3 意图框架与系统能力协同
再往前一步,是与 HarmonyOS 的意图框架、系统 AI 能力做协同:
- 意图分发:用户的自然语言意图,由系统框架分发到 RoadVision 的 Skill,App 不必常驻前台;
- 系统能力调用:取帧可以复用系统相机能力、影像可以从系统相册按授权读取,安全边界由系统管控;
- 跨设备接续:手机上拍的路况,可以流转到车机上播报——这正是征文里提到的「任务接续、数据流转」的典型场景;
- 端云协同:端侧负责采集与交互,鸿眸大模型在昇腾云侧推理,结果回流端侧。端侧要什么响应速度、云侧要什么模型能力,各司其职。

7.4 我对「Agent 化」的一点务实思考
说点实在的。把一个 App 改造成 Agent,最难的不是接框架,而是「能力的原子化与语义化」。
一个能被智能体可靠调度的 Skill,必须满足:单一职责、输入明确、输出结构化、可组合。我这个项目之所以能比较自然地 Agent 化,恰恰是因为它从一开始(虽然当时没这么想)就把「路况识别」做成了一个纯粹的、输入输出都规整的原子能力——后端一个接口进、一段结构化 JSON 出。
反过来说,如果一个功能的逻辑深度耦合在 UI 事件回调里、输出是一堆散装的界面状态,那它几乎无法被智能体调度。Agent 时代对开发者提出的第一个要求,其实是「架构解耦」这个老话题的新语境。 把业务能力从 UI 里剥离出来、做成可独立调用的服务,是走向 Agent 的前提。
这也回扣了本文第二章的架构决策——「App 只认后端,后端才认模型」这套分层,除了安全和可控,还有一个当时没料到的红利:它让整个识别能力天然就是「Agent-ready」的。

八、完整技术栈盘点
把这个项目用到的技术栈完整列一遍,方便对照:
端侧(HarmonyOS App)
- 语言:ArkTS(严格类型模式)
- UI 框架:ArkUI(声明式,
@Component/@Builder/@StorageProp) - 系统能力:
@kit.ArkUI(window 沉浸式)、@kit.MediaLibraryKit(相册选择)、@kit.NetworkKit(http)、@kit.BasicServicesKit(request/上传)、@kit.ArkData(Preferences 持久化)、@kit.CoreFileKit(文件读写) - 工具链:DevEco Studio、hvigor、hdc
- 状态管理:
AppStorage+@StorageProp全局响应式 - 设计:自建 Theme token 系统(颜色/渐变/圆角/阴影),深色科技风
服务端(推理中转)
- 语言/框架:Python + FastAPI + Uvicorn
- 多媒体:ffmpeg / ffprobe(视频抽帧)
- 网络:httpx(异步调用推理服务)
- 异步:asyncio(视频任务后台协程 + 进度轮询)
模型侧
- 自研视觉大模型:鸿眸 HongMou-VL
- 训练/推理硬件:华为昇腾 Ascend NPU
- 计算架构:CANN 异构计算
- 训练范式:多模态图文对齐预训练 + 行车场景指令微调(SFT),对齐结构化 JSON 输出
架构关键词
- 三层解耦(App / 后端 / 模型)、统一数据契约、结构化输出、端云协同、Skill 化、意图驱动
十、小项目,大时代
回头看,这个「看路」的 App 不算多大的项目。但它几乎把我想验证的东西都串起来了:一个自研的视觉大模型(鸿眸)、一套跑在华为昇腾上的训练与推理、一个鸿蒙原生的端侧体验、一条清晰的三层解耦架构,以及一条从「传统 App」走向「智能体 Skill」的演进路径。
HDC 2026 让「Agent 时代」这四个字变得具体。我最大的体会是:迈入 Agent 时代,对开发者来说不是要学一套全新的东西,而是要用新的视角重新审视老的功夫。 架构解耦、能力原子化、输出结构化、端云协同——这些原本就是好工程的标志,只是在 Agent 语境下,它们从「加分项」变成了「入场券」。
自研模型「鸿眸」还在持续迭代,昇腾上的训练也在继续。下一步,我会把它真正接进鸿蒙的智能体框架,让用户能对小艺说一句「看看前面路况」,就得到一次完整的路况研判——那时候,它才算真正「迈入了 Agent 时代」。
跟鸿蒙一起,大展鸿图。这条路,才刚开始。

鲲鹏昇腾开发者社区是面向全社会开放的“联接全球计算开发者,聚合华为+生态”的社区,内容涵盖鲲鹏、昇腾资源,帮助开发者快速获取所需的知识、经验、软件、工具、算力,支撑开发者易学、好用、成功,成为核心开发者。
更多推荐




所有评论(0)