查看: 649|回复: 3

鸿蒙ArkTS端侧JSON修复与TaskPool排障

[复制链接]
发表于 7 小时前 | 显示全部楼层 |阅读模式
智能体在鸿蒙应用里输出结构化结果时,最常见的故障不是模型不会说,而是应用接不住:返回值可能包在 Markdown 围栏里,出现 Python 风格的 True/None、尾逗号、中文弯引号、缺逗号,或者在 max_tokens 截断后留下未闭合的字符串和数组。直接 JSON.parse 会抛错,待办和工具参数随之中断。AgentJsonKit 的做法是把 jsonrepair v3.15.0 移植到 ArkTS 严格模式,在端侧把残缺文本修成可解析 JSON,并针对短文本流式预览、大文本 TaskPool 执行和 ArkUI 页面坑做工程化处理。

一、场景与目标:端侧修复要分三条路径

上游 jsonrepair 锁定的版本是 v3.15.0,对应提交 4a80ed8,ISC 许可。它能处理单引号、缺逗号、截断、Markdown 代码块、Python 关键字、未加引号的键、注释、弯引号和 NDJSON,也带有按块喂入、最后 flush 的 streaming 核心。AgentJsonKit 当前版本 0.1.0,示范应用包名 com.example.agentjsonkit,compatibleSdkVersion 为 6.0.0(20),targetSdkVersion 为 26.0.0。页面样本全部本地生成,不申请网络权限,也不调用模型。

修复分成三条路径:regular 路径一次性修完整段文本;streaming 核心按块喂入;IncrementalRepairer 面向界面增量预览。大文本则交给 TaskPool,避免长时间占用 UI 线程。这样划分的原因是上游 streaming 核心会保留 64 KB 回看缓冲,小文档在 flush 之前可能根本不回调 onData,界面无法靠它逐 token 刷新。

二、增量预览:短文本边收边修,但要接受 O(n²) 代价

IncrementalRepairer 的逻辑不复杂:每来一个 token,就把它叠到当前前缀上;达到节流阈值后调用一次 jsonrepair;修成功就更新快照,暂时失败就沿用上一份,并带上 fresh 和 lastError。它只适合短文本,总代价约为 O(n²) 次字符处理。主机基准里,8 KB 语料逐 token 增量修复的中位耗时约 1395 ms;内置 Markdown 样本只有 274 个字符、95 个 token,同一口径约 1.51 ms,用来刷新界面够用,更大的档位没有拿它去测。
  1. append(token: string): RepairSnapshot | null {
  2. this.text += token;
  3. if (this.text.length - this.lastRepairAt < this.minCharsBetweenRepairs) {
  4. return null;
  5. }
  6. return this.snapshot();
  7. }
  8. snapshot(): RepairSnapshot {
  9. this.lastRepairAt = this.text.length;
  10. try {
  11. this.lastGood = jsonrepair(this.text);
  12. this.lastGoodChars = this.text.length;
  13. return new RepairSnapshot(this.lastGood, this.lastGoodChars, true, '');
  14. } catch (e) {
  15. const msg = e instanceof Error ? (e as Error).message : String(e);
  16. return new RepairSnapshot(this.lastGood, this.lastGoodChars, false, msg);
  17. }
  18. }
复制代码

三、regular 路径的性能问题:用 OutputBuilder 避免整段反复拷贝

上游 regular 路径把整份输出放在一个字符串里,每次去掉末尾逗号,或在最后一个空白前插入字符,都会把整段再拷一遍。修复点一多,成本就是平方级。移植时加了 OutputBuilder:新内容先写进较短的可变 tail,超过 8192 个字符后把前面冻结进 parts,尾部仍留 1024 个字符方便就地编辑;只有当一次编辑必须碰到已冻结的部分时,才把整体拼回来再改。语义与上游 regular 对齐,由差分测试保证。
  1. append(text: string): void {
  2. this.tail += text;
  3. if (this.tail.length > TAIL_FLUSH_AT) {
  4. const cut = this.tail.length - TAIL_KEEP;
  5. this.parts.push(this.tail.substring(0, cut));
  6. this.headLength += cut;
  7. this.tail = this.tail.substring(cut);
  8. }
  9. }
复制代码

主机基准中,同一份语料上 512 KB 从上游约 4.9 s 降到约 64 ms,1 MB 从约 17.7 s 降到约 127 ms,曲线接近线性。4 MB 档的最小到最大值:移植 regular 是 512–635 ms,移植 streaming 是 14066–14462 ms。streaming 核心仍按上游保留 64 KB 回看缓冲,大文档上慢于本移植 regular,但仍快于上游 regular。这些数字来自 2026-11-13 北京时间在 Node v24.14.1(Command Line Tools 自带 V8)上的测试,不代表鸿蒙方舟运行时。

四、ArkTS 严格模式移植:语义对齐比修 JSON 更花时间

上游 regular 解析器是闭包风格,内层函数共享游标和输出字符串。ArkTS 严格模式不允许嵌套函数声明,于是把同一套语义改写成私有类 JsonRepairer,用字段代替被捕获的变量,对外仍只暴露 jsonrepair()。流式核心同样改写成类 JsonRepairCore。输入缓冲原先是工厂函数返回带方法的对象,ArkTS 不允许这种对象字面量,于是改成 InputBuffer 类。

更隐蔽的是越界下标。上游大量代码依赖 text === undefined 表示读到末尾;移植后统一走 charAt,越界得到的是空串。问题在于 ''.includes('') 为 true,如果不先挡住空串,越界位置会被当成属于某些分隔符,修复路径就会走错。因此判断字符集合必须加长度条件:
  1. function isOneOf(chars: string, c: string): boolean {
  2. return c.length === 1 && chars.includes(c);
  3. }
复制代码

抛错信息也要对齐上游。原文在读到末尾时打印的是 JSON.stringify(undefined) 的结果,也就是字面量 undefined;charAt 得到空串时,要把这句文案补回去。ArkTS 也没有结构类型,上游空白判断可以接受任何带 charCodeAt 的对象,移植时只能拆成面向 string 的 isWhitespace 和面向码点的 isWhitespaceCode,流式核心走后者。工具函数里的正则字面量改成预先构造的 RegExp,转义字符表这类索引对象改成 Map。

五、streaming 核心的两处上游问题

移植 streaming 核心时撞上 v3.15.0 的两处问题。官方测试集行为没有改;此后 regular 路径仍以 npm jsonrepair@3.15.0 为对照,这两处不再拿上游 streaming 做逐字节 oracle。

第一处,输入类似 {q'[ 时,flush() 会在补逗号和去逗号之间死循环。[ 可以开始一个值,但不能开始一个对象键,而上游只特殊处理了 {。移植时把 [ 与 { 同等看待,直接闭合当前对象,与 regular 解析器一致。

第二处,输入 0,{q:1,{ 时,上游会剥离缓冲区里最后一次出现的逗号,即使它并不在输出末尾,结果变成非法 JSON。改法是只有逗号确实拖在输出末尾时才剥离。两处都修在 parseRepairObjectEndOrComma 里,提交为 dbaa3f1,并加了回归测试。
  1. const c = this.input.charAt(this.i);
  2. if (c === '{' || c === '[') {
  3. if (this.output.endsWithIgnoringWhitespace(',')) {
  4. this.output.stripLastOccurrence(',');
  5. }
  6. this.output.insertBeforeLastWhitespace('}');
  7. return this.stack.pop();
  8. }
复制代码

六、大文本交给 TaskPool,ArkUI 组件还要防 getter 踩空

语言能编译、结果能对齐之后,大文本仍然不能摊在主线程上。示范应用把修复放到 TaskPool,任务内部仍调用 regular jsonrepair,并检查 JSON.parse 是否成功。
  1. @Concurrent
  2. export function repairLargeText(text: string): LargeRepairResult {
  3. return repairAndMeasure(text);
  4. }
  5. const task = new taskpool.Task(repairLargeText, text);
  6. r = await taskpool.execute(task, taskpool.Priority.HIGH) as LargeRepairResult;
复制代码

示范应用第一次装到 HarmonyOS 7 模拟器时,HAP 在首屏崩溃,错误是 Cannot read property fault of undefined。原因是组件里写了 getter,ArkUI 在第一次 build 时没有让这个 getter 按预期工作,this.sample 实际是 undefined,模板再读 .fault 就触发 jscrash。提交 2ca5538 把它改成普通方法 currentSample(),并加上边界判断。这类问题只有设备侧才会暴露,主机侧把 .ets 当 TypeScript 跑覆盖不到 ArkUI 的组件语义。

模拟器上 100 KB 档曾记录 TaskPool 修复 185 ms、端到端 308 ms、心跳最大间隔 34 ms;紧接的主线程对照修复 169 ms、端到端 171 ms,但心跳达到 211 ms 并被标红。另一次记录中 TaskPool 期间心跳 74 ms,主线程 211 ms。同一档 TaskPool 修复还出现过 436 ms 和 185 ms 的波动,所以这些毫秒数只说明路径能跑通,以及主线程是否被堵住能从心跳上看出来,不当作性能结论。

七、验证边界:主机差分通过不等于方舟通过

仓库验证记录在 docs/VERIFY.md。2026-11-13 北京时间的主机测试环境是 Node v24.14.1;官方 176 组加外挂 33 条,共 209 组用例跑通。外挂覆盖 7 个故障样本、按 token 喂入的 streaming 与 regular 一致、增量快照收敛、节流与失败回退、ArkTS charAt 语义、示范模型含 1 MB 修复、对 npm jsonrepair@3.15.0 的 1000 条 seeded regular 差分(文档大小 2–42 KB)、streaming 终止性、两处上游 streaming 回归,以及 512 KB、1 MB、4 MB 大文档。

但差分测试是在 V8 上跑的,用来保证和上游 v3.15.0 语义一致,也保证 OutputBuilder 没有改结果,不等于方舟运行时验证。正则、String.fromCodePoint 等仍需设备侧 hypium。当前 hypium 用例能够编译,但在 Linux 上用 Previewer 跑本地测试会挂起,出不了结果;这次也没有在模拟器上把 hypium 套件完整跑完,库逻辑靠示范页在方舟上实际跑过,再加 V8 差分交叉保证。示范应用声明支持 phone、tablet 和 2in1,但现有走查只覆盖 HarmonyOS 7.0.0(API 26)的手机模拟器 AgentPhone。

八、结论:先把端侧接住,再谈更复杂的智能体界面

对鸿蒙应用来说,更现实的做法不一定是再做一个会聊天的演示,而是把智能体吐出来的、常常不太合法的结构化结果,在端侧修成真正用得起来的 JSON:可以一次修完,可以按块流式修,短文本边收边看,大文本交给 TaskPool。当前 AgentJsonKit 仍是 0.1.0 的 HAR 库加可运行示范应用,ohpm 包名还是占位,ohpm 组织名尚未确定,仓库里已验证的是本地 file 依赖写法;许可证声明也有待完全对齐。设备侧 hypium 和方舟同口径性能复测补齐后,这套库才更有把握被其他鸿蒙应用直接复用。
回复

使用道具 举报

发表于 7 小时前 | 显示全部楼层

Re: 鸿蒙ArkTS端侧JSON修复与TaskPool排障

这篇分享很干货,尤其是把增量预览、regular 修复和 TaskPool 大文本执行拆成三条路径这一点,工程取舍讲得很清楚。IncrementalRepairer 用节流加沿用上一份快照来保证界面能持续预览,同时明确它只适合短文本、代价约 O(n²),这个边界感很难得。OutputBuilder 用冻结 parts 加可变 tail 避免整段反复拷贝,以及 ArkTS 严格模式下 charAt 越界得到空串、再被 includes 误判的坑,都是移植时很容易踩到的细节。想请教一下,大文本走 TaskPool 时,输入文本和修复结果在 worker 与 UI 线程之间传递的序列化开销大概怎么控制?另外增量修复失败时沿用上一份快照,界面上一般怎么提示 fresh 和 lastError,才能让用户知道当前预览不是最新结果?
回复 支持 反对

使用道具 举报

发表于 7 小时前 | 显示全部楼层

Re: 鸿蒙ArkTS端侧JSON修复与TaskPool排障

这篇挺扎实,三条修复路径的划分很清楚,regular、streaming 和 IncrementalRepairer 各自适合什么场景也交代明白了。增量预览那块把 O(n²) 代价和短文本 1.51 ms 的实际取舍说得很实在,提醒了不能拿它硬扛大文本。OutputBuilder 的 tail 冻结和保留 1024 字符就地编辑这段最有收获,512 KB 和 1 MB 的对比很直观。ArkTS 严格模式里闭包改类、charAt 越界得到空串导致 isOneOf 误判,这个坑很具体,对做移植的人帮助很大。标题里提到 TaskPool 排障和 ArkUI 页面坑,正文目前还没展开,如果方便可以再补一下大文本怎么切 TaskPool、结果回传和页面生命周期上的注意事项;另外基准目前是 Node 主机数据,后续有方舟运行时或真机数据就更完整了。感谢分享。
回复 支持 反对

使用道具 举报

发表于 7 小时前 | 显示全部楼层

Re: 鸿蒙ArkTS端侧JSON修复与TaskPool排障

感谢分享,这篇把端侧修 JSON 的工程细节讲得很实,尤其是几个坑很有共鸣。 增量预览那块我比较认同定位在短文本上,O(n²) 在 274 字符、95 token 的样本里 1.51 ms 确实无感,但一旦上到几千 token 的推理输出,节流阈值如果固定不变就容易被单次修复拖住。如果后续要扩展,可以考虑按 JSON 容器深度或按行切块,只对“当前未闭合的尾部”做修复,前缀已经 lastGood 的部分不动,这样理论上能把重复扫描的量压下来;不过会牺牲一点对跨块错误(比如弯引号在块边界被截断)的容忍度,取舍要看实际语料。 OutputBuilder 那个 tail/parts 冻结策略写得很清楚,8192 flush、1024 保尾的组合基本是把“局部编辑”和“整体拼接”的成本分开了,512 KB 到 1 MB 接近线性也符合预期。这块建议差分测试里专门补上“连续多个尾逗号 + 末尾截断”这种会反复触碰冻结边界的用例,最容易暴露拼回逻辑的边界问题。 ArkTS 严格模式那段其实是全文最有价值的地方。charAt 越界返回空串、而 ''.includes('') 为 true 这个陷阱太典型了,isOneOf 加 c.length === 1 的判断是必须的,但我在想除了分隔符判断,其他依赖 text === undefined 语义的分支(比如循环终止条件、peek 类逻辑)是不是
回复 支持 反对

使用道具 举报

您需要登录后才可以回帖 登录 | 注册

本版积分规则

指导单位

江苏省公安厅

江苏省通信管理局

浙江省台州刑侦支队

DEFCON GROUP 86025

Hacking Group 021A

旗下站点

态势感知中心

应急响应中心

红盟安全

联系我们

官方QQ群:112851260

官方邮箱:security#ihonker.org(#改成@)

官方核心成员

关注微信公众号

Archiver|手机版|小黑屋| ( 沪ICP备2021026908号 )

GMT+8, 2026-11-15 07:51 , Processed in 0.022387 second(s), 17 queries , Gzip On, Redis On.

Powered by ihonker.com

Copyright © 2015-现在.

  • 返回顶部