朋友们,最近在折腾鸿蒙 PC 桌面端开发的时候,发现一个很有意思的需求:本地需要一个能调用 DeepSeek 的 AI 助手外壳,但市面上的工具要么是纯命令行的,要么只能跑在移动端,想在鸿蒙桌面端直接和模型对话、让它调用本地工具,资料少得可怜。网上关于 DeepSeek 的教程很多,关于鸿蒙开发的教程也很多,但把两者放在一起、再带上 agent 工具链(也就是 harness)的完整落地经验,几乎是空白。
这篇文章我会从零拆解一套“DeepSeek harness 鸿蒙 PC 桌面端”的闭环方案。不管你只是想做一个鸿蒙桌面端的 AI 聊天工具,还是想进一步实现“模型自动调用本地函数、操作文件、查询系统信息”这类智能体(Agent)能力,本文的代码和思路都可以直接复用。
适合的读者包括:正在学习鸿蒙 ArkTS 开发的初学者、想把大模型能力集成到 PC 桌面端应用的客户端工程师,以及对 Agent 工具链感兴趣的 AI 应用开发者。
1. 背景:为什么要把 DeepSeek harness 搬到鸿蒙 PC 桌面端
1.1 DeepSeek 是什么,为什么值得集成
DeepSeek 是一个国内开源的大语言模型系列,它不仅提供了功能强大的云端 API,还开放了多个规格的模型权重,支持本地部署。对开发者来说,DeepSeek 最大的吸引力在于三点:
- 上下文窗口大,可以处理长文档和复杂对话。
- API 兼容 OpenAI 的接口风格,迁移成本低。
- 支持工具调用(Function Calling / Tool Use),这是开发 Agent 应用的关键能力。
过去我们在 PC 桌面端集成 AI,通常是在 Electron 或者传统 C++ 应用里写死一堆调用逻辑。现在鸿蒙 PC 桌面端出现后,很多团队希望用一套代码同时覆盖手机、平板、PC 场景,那么基于 ArkTS 开发原生鸿蒙应用就是绕不开的选择。
1.2 什么是 harness:Agent 工具链与 Skill 机制
“harness” 在英文里的本意是“马具、束缚装置”,在 AI Agent 领域,它指的是“把模型连接到外部世界的控制框架”。你可以把它理解为套在模型外面的一层运行环境:
- 模型本身只是文字输入输出,它不知道怎么读文件、不知道怎么发 HTTP 请求。
- harness 负责把用户指令解析成模型能理解的结构,再让模型通过“工具调用”间接操作外部系统,最后把工具执行结果回传给模型,形成闭环。
最近热门的 DeepSeek harness 话题,本质上讨论的就是怎么打造一个稳定的 Agent 运行框架,它通常包含几部分:
| 组件 | 作用 |
|---|---|
| LLM 客户端 | 封装模型 API,处理超时、重试、上下文管理 |
| Skill 注册表 | 把能力封装成可命名的技能,模型通过名称调用 |
| 工具执行器 | 真正执行模型请求的函数,并返回结果 |
| Agent 循环 | 判断是否继续调用工具,还是直接给出最终答案 |
简单说:harness 就是 Agent 的“骨架”,DeepSeek 是“大脑”,骨架负责给大脑提供手和脚。
1.3 鸿蒙 PC 桌面端的生态位置
鸿蒙系统目前已经不再局限于手机,而是逐步覆盖平板、车机、PC 等全场景设备。传统的“鸿蒙 = 手机系统”认知正在被打破,尤其随着开源鸿蒙 PC 版本的消息不断放出,很多开发者开始提前布局桌面端应用。
当前鸿蒙 PC 桌面端的应用形态主要有三类:
- 纯 ArkUI 原生应用:性能最好,适合工具类、效率类应用。
- Web 混合应用:通过 WebView 加载 H5 页面,适合已有 Web 资产。
- 跨端迁移应用:从 Electron、Tauri 或其他跨平台框架迁移而来。
AI 助手类应用最适合原生 ArkUI 实现,因为需要频繁操作界面状态、需要原生网络能力、需要本地工具调用权限,这些用跨端壳子反而绕路。
1.4 本文实战目标
为了不过于抽象,我设定一个具体的实战项目:开发一个鸿蒙 PC 桌面端的“DeepSeek Harness 桌面助手”,它具备以下能力:
- 在窗口中输入问题,调用 DeepSeek API 返回对话结果。
- 内置一个简易工具注册表,模型在回答中可以请求调用“获取当前时间”和“计算表达式”等本地工具。
- 通过 Agent 循环自动执行工具,并把结果整理成最终回答。
- 完整展示 ArkTS 网络请求、JSON 解析、UI 状态管理代码。
整个项目不依赖第三方 UI 库,不依赖外部 Agent 框架,只使用 DevEco Studio 创建的鸿蒙原生工程和 DeepSeek API,保证例子足够简单、可运行。
2. 开发环境准备与基础概念
2.1 环境清单
开始动手之前,请先确认你的开发环境。虽然鸿蒙工具链更新比较快,但下面的清单是通用基线:
| 项目 | 推荐配置 |
|---|---|
| 操作系统 | Windows 10/11 或 macOS,建议 64 位 |
| IDE | DevEco Studio(最新稳定版即可) |
| SDK | HarmonyOS SDK 5.x 或更高,API 12+ |
| 开发语言 | ArkTS(TypeScript 的超集) |
| 模型服务 | DeepSeek 开放平台账号,获取 API Key |
| 网络环境 | 开发机能访问公网,目标设备或模拟器网络正常 |
版本需要根据你的项目实际情况调整,本文示例以常见环境为例,重点演示配置思路。如果你本机已经安装了更高版本的 DevEco Studio,也没有问题,API 12 之后的 ArkUI 接口整体保持兼容。
2.2 创建鸿蒙桌面端工程
打开 DevEco Studio,创建一个新工程:
- 模板选择:
Empty Ability或Application模板都可以。 - 项目类型:选择 HarmonyOS 应用。
- 设备类型:除了 Phone,建议把 Tablet 和 PC 勾选上,方便后续在桌面形态下调试。
创建完成后,你会得到一个标准目录结构,关键文件如下:
| 文件路径 | 作用 |
|---|---|
entry/src/main/module.json5 | 应用模块配置,声明权限、入口页面 |
entry/src/main/ets/entryability/EntryAbility.ets | Ability 入口,负责加载页面 |
entry/src/main/ets/pages/Index.ets | 应用首页,我们主要在这里写 UI |
entry/src/main/ets/utils/ | 自建工具目录,放网络请求等逻辑 |
build-profile.json5 | 构建配置文件,管理签名和目标设备 |
桌面端开发有一个重要区别:窗口大小可能比手机大很多。如果追求体验,建议在首页中判断windowStage.getMainWindow()的宽高,或者直接用自适应布局(如Row、Column配合layoutWeight)。
2.3 工程结构与权限配置
鸿蒙应用访问网络时,需要在module.json5中声明ohos.permission.INTERNET权限。否则运行时请求会直接报错。注意 INTERNET 属于 normal 级别权限,不需要动态授权,直接在配置中声明即可。
// 文件路径:entry/src/main/module.json5 { "module": { "name": "entry", "type": "entry", "description": "$string:module_desc", "mainElement": "EntryAbility", "deviceTypes": [ "phone", "tablet", "2in1" ], "deliveryWithInstall": true, "installationFree": false, "pages": "$profile:main_pages", "requestPermissions": [ { "name": "ohos.permission.INTERNET", "reason": "$string:internet_permission_reason", "usedScene": { "abilities": [ "EntryAbility" ], "when": "inuse" } } ] } }这里把2in1也加入deviceTypes,是为了让应用可以跑在支持桌面形态的设备上。requestPermissions中的reason字段在部分 API 版本中需要配置,所以我在示例中写了一个字符串资源。如果编译报错,可以去掉reason和usedScene再试。
2.4 DeepSeek API 快速认知
DeepSeek 开放平台提供了 OpenAI 兼容的接口,核心参数如下:
- 接口地址:
https://api.deepseek.com/v1/chat/completions - 请求方法:POST
- 请求头:
Content-Type: application/json,Authorization: Bearer <你的API_Key> - 模型名称:
deepseek-chat(通用对话),deepseek-reasoner(推理增强)
最小请求体示例:
{ "model": "deepseek-chat", "messages": [ { "role": "user", "content": "你好,请介绍一下你自己" } ], "stream": false }如果希望模型支持工具调用,还需要在请求体中传入tools数组,这个我们下一节详细拆解。
3. 核心原理:Harness 的架构拆解
3.1 最小 Agent Harness 的数据流
要理解 DeepSeek harness 在鸿蒙桌面端的运行方式,先画出一个清晰的数据流。下面用步骤描述代替流程图:
- 用户在界面输入消息。
- ArkTS 代码组装
messages数组,并把内置的工具定义列表(tools)一起发送给 DeepSeek。 - DeepSeek 返回两种可能:
- 直接给出最终文字回答;
- 返回一个或多个
tool_calls请求,表示“我需要调用某个工具”。
- 如果返回
tool_calls,鸿蒙端解析工具名称和参数,从本地注册表中找到对应函数执行。 - 工具执行结果以
role: "tool"的消息追加到messages。 - 把完整的消息历史再次发送给 DeepSeek。
- 直到模型不再请求工具,输出最终回答,界面渲染结果。
这个循环就是 harness 的心脏。哪怕以后换成其他模型,只要它兼容 OpenAI 的 tool calling,这个流程都可以复用。
3.2 Tool / Skill 注册机制
为了让模型知道“你的环境里有哪些可用工具”,我们必须把工具描述发给模型。DeepSeek 的 tools 参数格式和 OpenAI 保持一致,一个工具定义通常包含:
{ "type": "function", "function": { "name": "get_current_time", "description": "获取当前系统时间", "parameters": { "type": "object", "properties": {}, "required": [] } } }当模型决定调用get_current_time时,返回的tool_calls[0].function.name就是get_current_time,参数在arguments字段中,是一个 JSON 字符串。
在 ArkTS 里,我们用一个 Map 来模拟注册表:
type ToolHandler = (args: string) => string; const toolRegistry: Map<string, ToolHandler> = new Map(); function registerTool(name: string, handler: ToolHandler): void { toolRegistry.set(name, handler); } function executeTool(name: string, args: string): string { const handler = toolRegistry.get(name); if (!handler) { return JSON.stringify({ error: `tool ${name} not found` }); } return handler(args); }这里ToolHandler接收模型传来的 JSON 字符串参数,返回一个字符串结果。返回的字符串会被直接塞进后续请求的 tool 消息中,所以最好返回 JSON 格式,方便模型理解。
3.3 DeepSeek 模型调用中的 tools 参数
把工具定义组装好后,最终请求体长这样:
{ "model": "deepseek-chat", "messages": [ { "role": "user", "content": "现在几点了?顺便帮我算一下 123*456" } ], "tools": [ { "type": "function", "function": { "name": "get_current_time", "description": "获取当前系统时间", "parameters": { "type": "object", "properties": {}, "required": [] } } }, { "type": "function", "function": { "name": "calculate_expression", "description": "计算数学表达式", "parameters": { "type": "object", "properties": { "expression": { "type": "string", "description": "要计算的数学表达式" } }, "required": ["expression"] } } } ] }如果模型发现“获取当前时间”和“计算表达式”这两个操作在本地可以实现,它就不会直接回答,而是返回 tool_calls 数组。这也是 DeepSeek 这类模型实现“智能体行为”的关键:模型自己决定何时调用工具,而不是由开发者硬编码。
3.4 ArkTS 中的异步模型与回调
鸿蒙 ArkTS 中做网络请求,常用@ohos.net.http模块。它的核心方法是http.createHttp()创建请求对象,然后调用request()发起请求。需要注意:
- 请求是异步的,不能在主线程里同步等待。
- 返回结果是一段 JSON 字符串,需要手动解析。
- 界面更新需要通过
@State变量触发,不能在回调外直接操作 UI。
下面是一个最基本的请求封装示例,之后我们会把它改造成完整的 harness 服务:
import http from '@ohos.net.http'; export function chatWithDeepSeek( apiKey: string, body: Record<string, Object>, callback: (error: string | null, result: string | null) => void ): void { const httpRequest = http.createHttp(); httpRequest.request( 'https://api.deepseek.com/v1/chat/completions', { method: http.RequestMethod.POST, header: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` }, extraData: JSON.stringify(body), expectDataType: http.HttpDataType.STRING, connectTimeout: 30000, readTimeout: 60000 }, (err, data) => { if (!err) { callback(null, data.result as string); } else { callback(JSON.stringify(err), null); } httpRequest.destroy(); } ); }注意,这里的Record<string, Object>是 ArkTS 中字典的常用写法。实际开发中,可以把它抽成一个独立的DeepSeekService,专门管理 API Key、请求体组装和响应解析。
4. 完整实战:开发 DeepSeek Harness 桌面助手
4.1 设计需求
我们的桌面助手需要三个功能模块:
- 聊天模块:输入内容、显示回答。
- 工具注册模块:内置两个工具,
get_current_time和calculate_expression。 - Agent 循环模块:自动判断模型是否请求调用工具,并循环执行直到得到最终回答。
界面布局采用上下结构:顶部是标题,中间是可滚动对话区,底部是输入框和发送按钮。为了适配桌面端的大屏,对话区使用Scroll包裹,并让消息卡片撑满宽度。
4.2 编写 DeepSeekService
在entry/src/main/ets/services/DeepSeekService.ets中封装网络请求和 Agent 循环。
先实现最核心的chatWithTools方法。它接收完整消息历史和工具定义,返回 DeepSeek 的响应对象。
// 文件路径:entry/src/main/ets/services/DeepSeekService.ets import http from '@ohos.net.http'; export interface ChatMessage { role: string; content: string; tool_calls?: Array<ToolCall>; name?: string; } export interface ToolCall { id: string; type: string; function: { name: string; arguments: string; }; } export class DeepSeekService { private apiKey: string = ''; private baseUrl: string = 'https://api.deepseek.com/v1/chat/completions'; constructor(apiKey: string) { this.apiKey = apiKey; } setApiKey(apiKey: string): void { this.apiKey = apiKey; } async chatOnce( messages: ChatMessage[], tools: Record<string, Object>[] ): Promise<Record<string, Object>> { if (!this.apiKey) { throw new Error('API Key 未配置'); } const requestBody: Record<string, Object> = { model: 'deepseek-chat', messages: messages as Object[], stream: false }; if (tools.length > 0) { requestBody['tools'] = tools as Object[]; } return new Promise((resolve, reject) => { const httpRequest = http.createHttp(); httpRequest.request( this.baseUrl, { method: http.RequestMethod.POST, header: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.apiKey}` }, extraData: JSON.stringify(requestBody), expectDataType: http.HttpDataType.STRING, connectTimeout: 30000, readTimeout: 60000 }, (err, data) => { if (!err) { try { const result = JSON.parse(data.result as string); resolve(result as Record<string, Object>); } catch (parseError) { reject(new Error(`JSON 解析失败: ${JSON.stringify(parseError)}`)); } } else { reject(new Error(`HTTP 请求失败: ${JSON.stringify(err)}`)); } httpRequest.destroy(); } ); }); } }这段代码把请求封装成了 Promise 风格,方便后续用async/await写 Agent 循环。需要注意,ArkTS 对any类型限制较多,要求尽量使用明确类型,所以我在接口定义上花了一点成本。
4.3 实现 Agent 循环和工具注册表
接下来写一个HarnessRunner,它负责维护消息历史、调用 DeepSeek、解析工具调用并循环。
// 文件路径:entry/src/main/ets/services/HarnessRunner.ets import { DeepSeekService, ChatMessage, ToolCall } from './DeepSeekService'; type ToolHandler = (argsJson: string) => string; interface ToolDefinition { name: string; description: string; parameters: Record<string, Object>; } export class HarnessRunner { private service: DeepSeekService; private toolHandlers: Map<string, ToolHandler> = new Map(); private toolDefinitions: ToolDefinition[] = []; private messages: ChatMessage[] = []; constructor(apiKey: string) { this.service = new DeepSeekService(apiKey); } registerTool( name: string, description: string, parameters: Record<string, Object>, handler: ToolHandler ): void { this.toolHandlers.set(name, handler); this.toolDefinitions.push({ name: name, description: description, parameters: parameters }); } buildToolsArray(): Record<string, Object>[] { const result: Record<string, Object>[] = []; for (let def of this.toolDefinitions) { result.push({ type: 'function', function: { name: def.name, description: def.description, parameters: def.parameters } }); } return result; } async chat(userContent: string): Promise<string> { this.messages.push({ role: 'user', content: userContent }); const tools = this.buildToolsArray(); let maxLoops = 5; for (let i = 0; i < maxLoops; i++) { const response = await this.service.chatOnce(this.messages, tools); const choice = (response['choices'] as Array<Record<string, Object>>)[0]; const message = choice['message'] as Record<string, Object>; const role = message['role'] as string; const content = (message['content'] as string) ?? ''; const toolCalls = message['tool_calls'] as ToolCall[] | undefined; this.messages.push({ role: role, content: content, tool_calls: toolCalls }); if (!toolCalls || toolCalls.length === 0) { return content; } for (let call of toolCalls) { const fnName = call.function.name; const argsJson = call.function.arguments; const result = this.runSingleTool(fnName, argsJson); this.messages.push({ role: 'tool', content: result, name: fnName, tool_call_id: call.id }); } } return '工具调用次数过多,已自动终止。'; } private runSingleTool(name: string, argsJson: string): string { const handler = this.toolHandlers.get(name); if (!handler) { return JSON.stringify({ error: `工具 ${name} 不存在` }); } try { return handler(argsJson); } catch (e) { return JSON.stringify({ error: `工具执行异常: ${JSON.stringify(e)}` }); } } }这里有一个细节值得注意:在把模型返回的message塞回messages时,如果原消息带tool_calls,必须原样保留。否则模型在第二次请求时不知道它刚才调用过哪些工具,容易产生上下文混乱。
另外,我在ChatMessage接口里假设了tool_call_id字段。DeepSeek 返回的 tool call 带有id字段,在追加 tool 结果时,要把 tool_call_id 关联上。实际运行中,如果发现模型报错,可以检查这个字段是否传对。
4.4 编写聊天界面
界面文件放在entry/src/main/ets/pages/Index.ets。
// 文件路径:entry/src/main/ets/pages/Index.ets import { HarnessRunner } from '../services/HarnessRunner'; interface DisplayMessage { role: string; content: string; } @Entry @Component struct Index { @State messages: DisplayMessage[] = []; @State inputValue: string = ''; @State loading: boolean = false; private runner: HarnessRunner = new HarnessRunner('你的DeepSeek_API_Key'); aboutToAppear(): void { this.initTools(); } initTools(): void { this.runner.registerTool( 'get_current_time', '获取当前系统时间', { type: 'object', properties: {}, required: [] }, (argsJson: string): string => { const now = new Date(); return JSON.stringify({ time: now.toLocaleString() }); } ); this.runner.registerTool( 'calculate_expression', '计算数学表达式', { type: 'object', properties: { expression: { type: 'string', description: '要计算的数学表达式,例如 123*456' } }, required: ['expression'] }, (argsJson: string): string => { try { const args = JSON.parse(argsJson) as Record<string, string>; const expression = args['expression']; const value = eval(expression); return JSON.stringify({ result: value }); } catch (e) { return JSON.stringify({ error: '表达式计算失败' }); } } ); } async sendMessage(): Promise<void> { const text = this.inputValue.trim(); if (!text || this.loading) { return; } this.messages.push({ role: 'user', content: text }); this.inputValue = ''; this.loading = true; try { const finalAnswer = await this.runner.chat(text); this.messages.push({ role: 'assistant', content: finalAnswer }); } catch (e) { this.messages.push({ role: 'assistant', content: `请求失败:${JSON.stringify(e)}` }); } finally { this.loading = false; } } build() { Column({ space: 12 }) { Text('DeepSeek Harness 桌面助手') .fontSize(24) .fontWeight(FontWeight.Bold) .margin({ top: 24 }) Scroll() { Column({ space: 10 }) { ForEach(this.messages, (msg: DisplayMessage) => { Row() { Text(msg.content) .fontSize(16) .padding(12) .backgroundColor(msg.role === 'user' ? '#1E90FF' : '#3A3A3A') .borderRadius(8) .fontColor(Color.White) .layoutWeight(1) } .width('100%') .justifyContent(msg.role === 'user' ? FlexAlign.End : FlexAlign.Start) }, (msg: DisplayMessage, index: number) => `${index}-${msg.role}-${msg.content.length}`) } } .layoutWeight(1) Row({ space: 8 }) { TextInput({ placeholder: '输入消息,例如:现在几点了?帮我算 123*456', text: this.inputValue }) .layoutWeight(1) .height(48) .onChange((value: string) => { this.inputValue = value; }) Button(this.loading ? '思考中...' : '发送') .enabled(!this.loading) .height(48) .onClick(() => { this.sendMessage(); }) } .width('100%') .padding({ bottom: 16 }) } .width('100%') .height('100%') .padding({ left: 16, right: 16 }) .backgroundColor('#1C1C1C') } }关于eval的使用,这里需要特别提醒:eval在鸿蒙应用里不是推荐的做法。我在这里只是演示工具调用流程,生产环境绝对不能用eval直接执行用户输入,否则会有严重安全风险。更稳妥的方案是用表达式解析库,或者在服务端计算。建议阅读本文的读者把计算工具替换成安全的数学表达式解析函数。
另外,ForEach的 key 生成函数要注意唯一性。我使用了index-role-content的组合。如果消息内容完全相同可能会触发缓存问题,实际开发中可以维护一个自增 id 字段。
4.5 在 DevEco Studio 中运行
把上述文件放入工程后,点击 Sync 让 IDE 下载依赖并编译。运行前请确认:
- 模拟器或真机已连接。
- 网络权限已声明。
- API Key 已填入
Index.ets中new HarnessRunner('你的DeepSeek_API_Key')。
点击 Run 后,应用会在桌面窗口打开。输入“现在几点了”或者“帮我算一下 123*456”,观察运行日志和界面输出。
如果一切正常,你第一次发送消息时会看到类似下面的情况:
- 用户输入问题。
- DeepSeek 返回一个 tool_calls,内容是
get_current_time或calculate_expression。 - 鸿蒙端执行工具,并把结果追加为 tool 消息。
- 模型基于工具结果生成最终自然语言回答,界面显示完整文字。
5. 代码逻辑详解:知识点拆解
5.1 为什么 model 用 deepseek-chat
DeepSeek 开放平台的模型名称有deepseek-chat和deepseek-reasoner两类。deepseek-chat是一个通用对话模型,响应速度快,适合日常会话和工具调用场景。deepseek-reasoner是推理增强模型,回答会更细致,但延迟更高,适合复杂问题分析。
在本文的 harness 场景中,如果工具调用频率较高,建议使用deepseek-chat,因为工具调用本身是一个多轮交互过程,响应速度直接影响用户体验。
5.2 消息历史的组装顺序
Agent 循环最容易出错的地方是消息顺序。按照 OpenAI 兼容接口的约定,完整的消息序列大致是:
[ { "role": "user", "content": "现在几点了?" }, { "role": "assistant", "content": "", "tool_calls": [ ... ] }, { "role": "tool", "content": "当前时间是...", "tool_call_id": "call_123" } ]如果tool_calls有两个,那么就必须追加两条tool消息,并且每条tool消息的tool_call_id对应各自的工具调用 id。
我在HarnessRunner.chat中,把一个完整回合里的所有tool_calls结果都先 push 进messages,再发送下一轮请求,这一步顺序不能乱。如果先发请求再补消息,或者把tool消息放在assistant消息前面,模型会直接报错或产生幻觉。
5.3 tool 返回结果的结构
工具返回结果也是一个字符串。为了节省 token,建议返回短而有效的 JSON。例如:
{ "time": "2026/1/28 21:00:00" }而不是返回一段完整的中文句子。模型能读懂 JSON 结构,并且会基于这个结构化结果生成用户可读的回答。
5.4 UI 刷新的时机
在 ArkTS 中,@State数组变量如果直接调用push方法,部分场景下可以触发刷新,但更稳妥的写法是重新赋值:
this.messages = [...this.messages, newItem];如果在回调中修改数组而不触发变更检测,界面可能不更新。本文示例使用this.messages.push(...),在 ArkUI 的@State数组下通常也能工作,但遇到不刷新问题时可以改写成展开式赋值。
6. 常见问题与排查思路
6.1 常见错误对照表
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 请求返回 401 Unauthorized | API Key 错误、为空或已过期 | 检查Authorization头,确认 API Key 没加多余空格 |
| 请求返回 400 Bad Request | messages 结构不合法,或 tools 格式错误 | 打印请求体,对照 OpenAI 兼容接口检查消息顺序 |
| 网络超时 | 开发机或模拟器无法访问 DeepSeek API | 检查网络代理、设备网络权限和防火墙设置 |
| 工具执行后模型不继续回答 | tool_call_id 未正确回传 | 确保tool消息的tool_call_id等于工具调用 id |
| 界面不刷新 | @State 数组变更未触发 UI | 改用重新赋值方式更新数组,或使用 id 字段强制刷新 |
| 鸿蒙请求报错 2300056 | Android 正常但鸿蒙异常,通常是网络权限、证书校验或代理配置差异 | 检查 module.json5 的 INTERNET 权限,确认系统代理设置 |
eval被限制或报错 | 鸿蒙运行环境出于安全原因不允许 eval | 用表达式解析库替换 eval |
6.2 排查顺序
如果第一次跑不通,建议按下面顺序排查:
- 先确认 API Key 能独立调通。可以用任意 REST 工具直接 POST 到 DeepSeek API,看看是否返回正常结果。
- 再确认鸿蒙工程编译通过,INTERNET 权限已声明。
- 在
DeepSeekService.chatOnce中打印请求体和响应体,观察模型返回的数据结构。 - 检查
tool_calls的字段名。DeepSeek 兼容 OpenAI,字段名一般是tool_calls、function.name、function.arguments。 - 最后检查 UI 层,看是数据没返回,还是数据返回了但界面没刷新。
6.3 关于 Electron 应用移植鸿蒙的启发
很多团队的现有桌面应用是 Electron 写的。如果想把类似应用迁移到鸿蒙 PC 桌面端,需要重点关注三块:
- 主进程逻辑迁移:Node.js 的 fs、net 模块需要用鸿蒙的
@ohos.file、@ohos.net.http替代。 - 渲染层替换:Electron 的 HTML/CSS 渲染需要改写成 ArkUI 组件。
- 系统能力调用:如系统托盘、文件对话框、快捷键等,需要使用鸿蒙系统 API 重新实现。
DeepSeek harness 类应用迁移时,核心难点其实不在 UI,而在本地工具的调用链。如果原来的工具依赖 Node 生态,迁移成本会比较高;如果工具层只是 HTTP 调用,那鸿蒙原生直接用@ohos.net.http即可。
7. 最佳实践与工程建议
7.1 不要把 API Key 写死在代码里
示例中为了方便演示,把 API Key 直接写在了new HarnessRunner('你的DeepSeek_API_Key')。这是绝对不能在正式工程里出现的做法。建议通过以下方式管理:
- 开发阶段:通过本地配置文件保存,并且把文件加入
.gitignore。 - 生产阶段:由服务端转发请求,客户端只拿临时 token。
- 一定不要把密钥传到代码仓库、云编译平台或日志系统。
可以定义一个Config.ets文件统一管理配置,但即使如此,该文件也不能提交到公开仓库。
7.2 工具函数要有超时和异常兜底
harness 中执行本地工具时,如果工具本身崩溃或者阻塞,会卡住整个 Agent 循环。建议所有工具都包一层异常捕获,并限制最大执行时间。在没有完整协程超时的环境下,可以在上层设置整体请求超时,确保循环不会无限等待。
7.3 控制消息长度
多轮工具调用会让消息历史增长很快。每次请求时,可以把最旧的消息截断,或者用滑动窗口只保留最近 N 轮。对于 DeepSeek 这种上下文很大的模型,窗口可以开得稍大,但要注意请求体积和 token 成本。
7.4 日志与调试信息
网络请求的返回结构比较复杂,建议在开发阶段开启详细日志。可以增加一个调试开关:
private debugMode: boolean = true; private log(msg: string): void { if (this.debugMode) { console.info(`[DeepSeekHarness] ${msg}`); } }日志至少打印以下几类信息:请求 URL、请求体摘要、响应状态码、工具调用名称、工具执行结果、最终回答。这样一旦出问题,能快速定位是模型问题、工具问题还是网络问题。
7.5 界面体验优化
桌面端窗口通常较大,建议把对话区设置最大宽度(例如 800px),并居中显示,避免文本行太长影响阅读。同时在加载状态中禁用发送按钮,防止用户连续点击产生并发请求。发送新消息时,可以自动让 Scroll 滚动到底部,这个在 ArkUI 中可以用Scroll的控制器实现。
7.6 工具职责边界
不要把所有逻辑都塞进工具函数。工具应该是“完成一项明确可命名的工作”,例如查询天气、计算表达式、读写本地文件。一个工具内部最好只做一件事,这样模型更容易理解何时该调用它。
8. 总结与后续学习路线
这篇文章围绕 DeepSeek harness 在鸿蒙 PC 桌面端的落地做了完整拆解,核心内容可以归纳为三条:
- harness 的本质是“模型 + 工具注册表 + 循环调度”,本文从零实现了一个最小可运行的 ArkTS 版本。
- DeepSeek 的 API 采用 OpenAI 兼容协议,只要消息历史和 tool_calls 处理得当,就能实现模型自动调用本地工具。
- 鸿蒙 PC 桌面端的应用形态正在快速丰富,原生 ArkUI 适合做 AI 助手类应用,网络权限和请求封装是整个链路的基础。
接下来可以继续研究的方向包括:
- 把
calculate_expression替换为安全的表达式解析库,解决 eval 的安全隐患。 - 增加本地文件搜索工具,让模型可以读取用户指定的文档。
- 接入流式输出(
stream: true),实现打字机效果。 - 尝试用
deepseek-reasoner做复杂推理场景,并对比工具调用的成功率。
如果本文对你有帮助,可以收藏备用。下一步建议你亲手把它跑起来,先只保留“获取当前时间”一个工具,再逐步添加新的工具。接口报错不可怕,关键是掌握消息历史和 tool_calls 的调试方法。祝你在鸿蒙桌面端开发中少踩坑、多出货。