先说结论:这是一个戴着眼镜、对准碑文、按一下确认键,就能逐字看到简体释文 / 字义 / 字源考据的小智能体,下面是它怎么长出来的,包括我踩过的几个坑。有些坑挺蠢的,但都真实。

一、项目简介:一块看不懂的碑

几年前在碑林,我站在一方唐碑前看了十来分钟,只认出三个字。手机拍下来放大,笔画能看清,但还是不认识;用识图软件搜,它告诉我这是「一块石碑」。

后来查资料才知道那是方墓志,写的是墓主人家世。当时的感觉不是「学到了」,而是「白站了十分钟」。

「字溯」解决的就是这十分钟。

戴上眼镜,对准石碑、摩崖石刻或者拓片,按一下确认,它拍一张照,把照片交给 LanguageModel 识读,然后把结果拆成三层摆在眼前:

  • L1:简体释文 + 拼音——先告诉你这是哪个字

  • L2:字义——这个字在这里大概什么意思

  • L3:字源、书体、典故——为什么是这个意思,它以前长什么样

目标用户有三拨:跟研学团看碑的学生、临帖前要对照原碑的书法爱好者、以及到了现场才想弄明白的文博爱好者。他们有个共同点——手上都有别的事,拿着手机、扶着眼镜、跟着队伍,能腾出来的只有一句话和一根手指。

所以这个产品的形态从一开始就定了:不是让你坐下来研究,是让你站在原地,用最少的动作看完。

二、场景与用户意图:他其实只想说一句话

真实的现场大概是这样:

站在碑前,看不懂,想知道写的是什么。

这句话可以拆成几个动作:掏出手机 → 打开相机 → 拍 → 打开识图 → 等 → 发现结果是一段百科 → 再搜单字 → 放弃。

眼镜上的链路要短得多:抬头、说一句(或者直接按确认)、看结果。中间那些步骤应该由界面自己组织,而不是交给用户去点。

我把它理解成一组有先后关系的意图:

用户想干的

界面上的承接方式

这段写的是什么

拍照 → 识读 → 出字卡

这个字什么意思

选中字卡 → 展开 L2

为什么这么写

再展开 → L3

念给我听

语音「朗读」/ 按钮「朗读」

记一下,回头查

保存笔记(照片 + 三层释文 + 时间)

考考我

开启研学 → 从已识别的字里出题

有一点需要说清楚:唤醒词这一层我做不了主。onVoiceWakeup 只接收平台预置的唤醒词,没有任何接口能注册「你好,字溯」。所以实际链路是「平台预置唤醒 → SpeechRecognition 收音 → 我自己的关键词路由」。这也是为什么代码里有一张 11 条规则的意图表,而不是一个唤醒词。

眼镜的输入方式也决定了交互不能照搬手机。onHeadGesture 只有点头和摇头,没有滑动、没有精确触摸。所以整套操作最后落在四个键上:方向键选、Enter 确认、Backspace 返回。

想清楚这一点之后,很多设计就不用纠结了:凡是需要「拖、滑、长按」的交互,全部不做。

三、方案设计:四个页面和一个状态机

3.1 智能体定义

AGENTS.md 里我写死了一条边界,这条边界后来救了我很多次:

古文字考释存在多种学术分歧,释读内容仅作科普参考,不作为学术定论。

它不只是免责声明,它还是行为约束:模型不能输出「定论」「确切无疑」这种词,遇到有分歧的考释必须写「一说」「或释」。界面上,取景页常驻一行小字;L3 展开时再加一句「不作为学术定论」。

另外三条主动放弃的能力,也写进了 manifest:

  • 不做 SLAM 空间锚定。enableWorldAwareness 不含 SLAM,释文卡片不做物理坐标绑定,就在取景界面里分层展示。

  • 不做自定义唤醒词,原因上面说了。

  • 不做上滑/下滑手势,只做方向键。

3.2 页面分工

四个页面,各管一件事:

页面

职责

pages/camera/camera

取景、拍照、识读、三层字卡、语音指令入口

pages/quiz/quiz

研学答题:出题、作答、校验、发徽章,页内连答

pages/notes/notes

笔记列表 + 详情,看完整三层释文

pages/badges/badges

徽章墙,4 列网格

主页面为什么把「取景」和「释文」放在同一屏?因为在现场,用户最怕的是「点进去之后找不到刚才那块碑」。取景框和字卡共用一块 240px 高的主区,有释文就显示字卡,没有就显示取景区提示——不跳页,不丢上下文。

3.3 状态流转

主页状态不多,但每个都得给用户一个明确的表情:

idle ──拍照──> recognizing ──成功──> success
                  │  ├──失败──> fail / anchor_lost(保留上次释文)
                  └──按确认──> cancel(回到 idle 或 success)

失败态这里我改过一次:最早失败就清空 glyphs,用户看到一片空白,第一反应是「刚才识别成功的字呢」。现在失败只改状态文字,上一次的释文和照片都留着,顶部加一句「本次识别未成功,以下为上次释文」。

3.4 能力调用

整条链路只有四类外部能力,全部是平台给的:

wx.media.createCameraContext().takePhoto()
        ↓ ArrayBuffer → wx.arrayBufferToBase64
LanguageModel.availability() / create() / session.prompt([text, image_url])
        ↓ 文本(一行 JSON)
parseGlyphs()  ← 自己写的容错解析
        ↓ 结构化字形数组
speechSynthesis.speak() / wx.setStorageSync()

没有后端,没有自建服务。识读完全靠端上的 LanguageModel。

四、AIUI 实现:几个真正花时间的点

项目结构就是标准 AIUI 的样子,ES module,.ink 单文件四块(script def / script setup / page / style):

字溯/
├── AGENTS.md          智能体定义
├── app.json           4 个页面路由 + 相机/录音权限
├── app.js             globalData(研学退出标志)
├── pages/
│   ├── camera/camera.ink
│   ├── quiz/quiz.ink
│   ├── notes/notes.ink
│   └── badges/badges.ink
└── utils/
    ├── promptBuilder.js   prompt 构造
    ├── resultParser.js    模型返回容错解析
    └── voiceRouter.js     意图路由 / TTS / 本地存储

4.1 从拍照到 base64

坑不多,但要按平台的方式来:

const photo = await this._takePhotoWithTimeout();
const ab = photo.data instanceof ArrayBuffer ? photo.data : /* 处理 TypedArray */;
const base64 = 'data:' + photo.mimeType + ';base64,' + wx.arrayBufferToBase64(ab);

navigator.mediaDevices.getUserMedia + ImageCapture 是浏览器 API,在这套运行时里会直接卡死页面,别碰。

4.2 让模型少说话

第一版 prompt 只写了「识读图片中的古文字,输出 JSON 数组」。结果模型很热情:先夸一句碑文,再解释自己的识读过程,最后给一段带 markdown 代码块的 JSON。用户等 20 秒,拿到一堆不能渲染的文字。

第二版把话说死:

只输出一行 JSON 数组,不要输出解释、标题、思考过程、前后缀或 markdown 代码块。
数组每个元素对应一个字,最多 20 个字。
所有字段都是字符串,每个字段不超过 30 字。
无法识读的字不要输出;整幅无石刻古文字时只输出 []。

还顺手修了原示例里的一个低级错误:示例 JSON 里的双引号没转义,模型照着抄,抄一次错一次。

改完之后,返回长度从几百字降到一百多字,响应也快了不少。

4.3 模型不听话怎么办

Prompt 再严也不能保证 100% 合规。所以解析这一层必须自己扛:

  • 去掉 ``` 代码围栏

  • 前面有解释文字?不管,从第一个 [ 或 { 开始做括号平衡扫描,找到完整的 JSON 片段

  • 返回对象?在 glyphs / chars / data / list 里找数组

  • 字段名不一样?simplified / char / character / hanzi / meaning 挨个试

  • 单个对象、字符串数组也认

核心那几行大概是这样:

for (let i = 0; i < src.length; i++) {
  const open = src[i];
  if (open !== '[' && open !== '{') continue;
  const close = open === '[' ? ']' : '}';
  let depth = 0, inStr = false, esc = false;
  for (let j = i; j < src.length; j++) {
    // 跳过字符串内部,避免把字符串里的括号当结构
    // ...
    if (depth === 0) { out.push(src.slice(i, j + 1)); break; }
  }
}

写完之后我拿 17 组样本跑了一遍:纯 JSON、代码块、前后带解释、对象包装、字段别名、字符串数组、单对象、空数组、纯闲聊、超长字段……全部通过。这一步比在真机上瞎试有用得多。

4.4 按键时序(这个坑最贵)

研学答题做完一题,界面上写着「确认或单击继续」。按一下确认,应用直接退出了。

排查了半天,问题不在 quiz 页的业务逻辑,在事件时序:

原来是在 onKeyDown 里直接 wx.navigateBack()。页面在 onKeyUp 之前就被卸载了,卸载之后那段 event.preventDefault() 永远不会执行——宿主把同一个 Enter 当成默认确认,又返回了一次。栈底被弹穿,应用退出。

修正后的规则,四个页面统一:

keydown 只记意图,keyup 先 preventDefault,再执行导航。

onKeyDown(e) {
  if (e.code === 'Enter') { this._pendingConfirm = true; return; }
  if (e.code === 'Backspace') { this._pendingBack = true; return; }
  // 方向键:只做选中项移动,不动页面
}

onKeyUp(e) {
  if (e.code === 'Enter' && this._pendingConfirm) {
    this._pendingConfirm = false;
    e.preventDefault();      // 先拦
    this._onConfirm();       // 再干
  }
}

顺手把研学也改了:原来答完一题要 navigateBack 回主页,由主页再 navigateTo 进答题页出下一题,一题一闪。现在直接在 quiz 页里出下一题;只有按返回,才回主页并把研学模式关掉。

五、交互与体验:走一遍完整流程

戴上眼镜,唤醒,说帮我释读这段碑文。

界面先变成识别中…(按确认取消)。这行字很重要——模型偶尔会慢,没有这行字,用户会以为死机了;有了这行字,他还知道可以按确认取消,不用干等 30 秒。

识读完成,主区换成字卡列表,顶栏显示12 字。方向键往下,选中第一张卡,按确认:

  • 第一次:展开 L2,日,太阳

  • 第二次:展开 L3,甲骨文象太阳之形,后加点+ 书体 + 典故

  • 第三次:收回 L1

永远只有一张卡是展开的。 这不是技术限制,是屏幕限制:480×352 里塞两张展开的卡,什么都看不清。

按朗读,TTS 从第一个字念到最后一个;再按一次停。播报做了 1.2 秒同内容防抖和 96 字截断——眼镜贴着头骨,重复播报比不播报更烦人。

按 笔记,底部弹一条已记笔记,两秒后自己消失。笔记里存的是照片 base64、识别原文、三层释文、时间戳和标签,下次打开还在。

按 研学,进入答题。题目不是写死的题库,是模型根据刚才那个字现出的四选一。答对了,徽章 +1,顶栏数字跟着变。

整个流程里,用户没有打开过一次键盘,没有输过一个字。

六、调试与验证:四个实机问题

模拟器里跑通不代表能用。实机第一轮,用户报了四个问题,每一个都值得记一笔。

问题一:识别成功,但主区是空的。

现象是取景框还在,字卡不出来。这里其实叠了两个原因:一是模型偶尔返回超长文本,旧解析器只认顶层数组 + simplified,返回 [],页面就走失败分支;二是 scroll-view 的高度是 flex: 1 1 auto + min-height: 0,真机上塌成了 0——数据有了,但没地方画。

两处都改了。解析改成容错解析;scroll-view 的父链全部改成固定 flex-basis。为了下次能一眼分清是没识」还是没渲染,我加了一行调试信息:

step=response photo=312KB raw=847 parsed=0

raw 有值、parsed 是 0,就是解析问题;两个都是 0,才是模型没返回。这行字在第一轮排查里省了我很多来回。

问题二:等太久,返回的还是一大段话。

Prompt 收紧 + 30 秒超时 + 识别中可取消。超时文案也分开写:超时说「识别超时,请重试或换更清晰的画面」,光线问题说「光线较弱」,取景异常说「取景不可用」,不让用户对着同一句「识别失败」猜。

问题三:研学点「继续」就退出。 上面 4.4 讲过了,keydown/keyup 的锅。

问题四:尺寸乱了。 按钮换行、免责提示压住按钮、笔记提示跑到屏幕外。按 4.5 的固定栅格重排,改完就没再动过。

验证这块我做了两件事:

  1. 写了一份 docs/VERIFY.md,从拍照、三层展开、朗读、记笔记、连答五题、返回退出,一条条勾。真机测试最怕「感觉能用」,勾完清单才知道哪里不能用。

  2. 脚本层先做静态检查。把每个 .ink 的 <script setup> 抽出来跑 node --check,解析器单独写 17 组用例。这些都不能替代真机,但能把「语法写错」这类低级问题挡在上机之前。

七、成果与复盘:能做什么,不能做什么

现在能做的:拍照识读甲骨文、金文、小篆、隶书(载体限碑刻、摩崖、拓片);逐字三层释文;语音指令覆盖释读、朗读、视图、笔记、模式切换、答题;研学答题页内连答,答对收徽章;笔记本地保存,随时回看;全程中文 TTS 反馈。

做不到的,我也写清楚:

  • 没有 SLAM,字卡不会钉在碑上,它只在取景界面里分层显示。这是平台能力边界,不是设计取巧。

  • 唤醒词只能用平台预置的,没法叫「你好,字溯」。

  • 单次识读上限 40 字。碑文动辄几百字,这是刻意的取舍:眼镜上没人能读完 300 字。

  • 考释有分歧的地方,它只能说「一说」「或释」,不能给结论。这不是能力不足,是这门学问本来的样子。

接下来的打算:

一是把「词组」做出来。现在是一字一卡,但碑文里「之」「其」「也」这种字单看没意义,得连着上下文。

二是把 resultParser.js 抽成模板。模型返回不规范是普遍问题,不只古文字会遇到,这套括号平衡 + 字段别名的写法换个场景也能用,放到社区里比留在自己项目里值钱。

三是补一次真正的现场测试。现在的验证都是「在能拍清楚的地方拍」,实际碑刻有反光、有苔痕、有玻璃罩。这些不是调 prompt 能解决的,得带着眼镜去碑林蹲一下午。

最后说回「意图驱动」这四个字。

做这个东西之前,我以为意图驱动的界面就是把聊天框搬到眼镜上:用户说一句,AI 回一段。做完之后发现不是。真正的难点在于——用户说的是「这写的啥」,他真正想要的是「别让我掏手机」。

界面要做的,是把这一句话翻译成尽量少的按键、尽量短的字、尽量明确的状态。三层释文是这么来的,两个字按钮是这么来的,失败时保留上次结果也是这么来的。

更多推荐