ENZH

v0.0.7:教TA读懂互联网

AI 学会阅读互联网的概念插画AI 学会阅读互联网的概念插画

TA在一本正经地胡说八道

聊天的时候,用户会分享链接。一个游戏战绩页、一篇文章、一个有意思的截图——这是人聊天时很正常的事。

但 v0.0.7 之前,Mio 读不了任何链接。TA看到消息里的 URL,然后自信满满地编造页面内容。

发给TA https://gd.ax0x.ai/?room=0U7VS6(一个棋牌游戏结果页),TA会编出数据:"胜率 57.1%,赢了 8 局。"全是假的。URL 对TA来说只是装饰——TA压根没有任何机制去读取真正的内容。

这比不知道更糟糕。

一个说"我看不了这个页面"的伴侣,至少是诚实的。一个编造你两秒钟就能验证的游戏数据的伴侣?不可信。

信任一旦碎了,就粘不回去。

三层瀑布

不是所有网页都一样。静态文章是纯 HTML。游戏面板用 JavaScript 渲染。有些页面主要是图片。一种抓取策略搞不定所有情况。

v0.0.7 用瀑布式架构——先试快的,不够再升级:

第一层:Jina Reader。 快速通道。把 URL 发给 r.jina.ai/{url},拿回干净的文本。不需要 API key。10 秒超时,3,000 字符截断。大多数静态页面秒出。

第二层:Browserless 爬取。 处理 JS 渲染页面。如果 Jina 返回的有效内容不到 100 字符,管线升级到 Browserless——启动无头浏览器,渲染页面,从 DOM 提取文本。专抓 Jina 静态抓取漏掉的 SPA 和动态内容。

第三层:截图 + 视觉。 终极方案。文本提取仍不到 100 字符?页面大概是图形密集型的——仪表盘、信息图、设计稿。Browserless 截一张 PNG,丢给 Mio 已有的 describeImage() 视觉模块(跟 v0.0.5 用的同一个)。AI 描述看到的内容。

失败通知。 三层都失败了?不是静默跳过,而是告诉 agent:[用户分享了一个链接:{url},但无法读取该网页内容]。agent 知道自己没读到,不会再编造。

100 字符的有效内容阈值是关键设计。低于这个数,提取就是"技术上成功但实际没用"。一个页面标题加一段 cookie 提示——这不叫读了页面。

两条路径接入

URL 浏览接入两条路径:

  • Telegram:在 processBatch() 里跑,媒体处理之后、routeMessage() 之前。提取 URL、获取内容、追加到消息上下文。
  • Web 聊天:在 prepareChatContext() 里跑,resolveMedia() 之后。同样的逻辑,不同的入口。

两条路径共用同一个 browseUrls() 函数。每条消息最多 5 个 URL,去重,去掉尾部标点。Promise.allSettled 确保一个 URL 失败不影响其他的。

Proxy Bug:一个藏了几个月的地雷

部署 v0.0.7 的时候,生产环境挂了。TypeError: connection is not a function。

packages/shared/src/db/client.ts 里的数据库 connection 导出是一个包裹空对象的 Proxy。属性访问没问题——connection.query(...) 正常工作——因为 Proxy 有 get trap 转发到真实连接。

但 postgres.js 用标签模板语法:

connection`SELECT * FROM memories WHERE user_id = ${userId}`

这是把 Proxy 当函数调用。对象 Proxy 没有 apply trap。函数 Proxy 才有。目标类型决定了可用的 trap。

修复:把 Proxy 目标从 {} 改成 function () {},加上 apply trap:

export const connection = new Proxy(
  function () {} as unknown as ReturnType<typeof postgres>,
  {
    get(_target, prop, receiver) {
      return Reflect.get(getConnection(), prop, receiver)
    },
    apply(_target, thisArg, args) {
      return Reflect.apply(
        getConnection() as unknown as (...a: unknown[]) => unknown,
        thisArg,
        args,
      )
    },
  }
)

这打断了记忆检索和人格提取——所有用标签模板模式的原始 SQL 查询全挂了。

这种 bug 在出事之前完全隐形:Proxy 用点号访问跑了几个月没任何问题,直到有人用了模板语法。一颗藏了几个月的地雷。

凌晨三点的调试故事

部署完 URL 浏览功能后,我马上测试。发了那个棋牌链接。agent 还是在编造数据。

第一反应:功能没部署成功?但日志显示部署正常。

真正的原因更简单也更烦人:那条 URL 是凌晨 3:03 发的,在部署之前。agent 说"再试一次"的时候,重试消息里没有 URL——只有"再试一次"。browseUrls() 找不到任何 URL 可抓。

用户重新发了链接。Jina 返回了完整的 16 行游戏状态——玩家名、分数、炸弹序列、赢家。agent 准确地描述了结果。功能完全正常。

教训:新功能看起来不工作的时候,先查触发输入是在部署之前还是之后到达的。旧消息不会自动获得新能力。

这种 bug 不是技术问题,是认知盲区。

23 个测试

browse.test.ts 覆盖了:

  • URL 提取:无 URL、单个、多个、去重、标点去除、上限 5 个
  • Jina 流程:成功、空响应、fetch 失败、非 ok 状态、认证头
  • Browserless 回退:Jina 不够 → 爬取成功,Jina 抛异常 → 爬取兜住,无 API key → 跳过
  • 截图 + 视觉:文本太短 → 触发截图,视觉返回无法识别 → 失败通知
  • 端到端:文本足够 → 跳过截图,全部失败 → 失败通知

23 个全过。类型检查干净。

变了什么

v0.0.7 之前,用户分享的每个链接都是一个等着爆的谎。agent 看到 URL,不理解它,然后用合理的虚构填补空白。注意到的用户会失去信任。没注意到的用户会得到错误信息。

两种都不能接受。

v0.0.7 之后,分享的链接变成了真实的上下文。游戏战绩页变成真实的战绩。文章变成摘要。图形密集的页面变成描述。读不了的页面诚实报告为不可读。

瀑布架构意味着所有内容类型都能处理,用户不需要知道也不需要关心是哪一层搞定了他们的链接。分享一个 URL,得到一个真实的回复。

就这样。简单,但重要。

几个正在想的事

跟朋友聊天时冒出了几个想法:

AI 写的人设故事不像人话。 Mio 现在所有人格预设都是 AI 生成的。朋友看了一个,马上指出几句"听着不太像人话"。恐怖谷不只在脸上——也在角色表达情感的方式里。修复方案大概是人写的预设,至少核心模板得是。AI 能生成数量,人能给出质感。

自定义人格是个坑。 我本能地想让用户从零写自己的人格。朋友的反应:"99.9% 的人懒得写,写的人也会乱写。"写得差的人格比通用人格更破坏沉浸感。agent 没法维持一个内部矛盾或信息不足的角色。

更好的方案是分层:

  • 大多数用户用模板 — 填空式。不是现在的预设,而是更细化:选一个基础性格,自定义关键的关系事件,定义几个重要记忆。足够的结构防止混乱,足够的灵活让人觉得是自己的。
  • 完全自定义给高级用户 — 愿意认真写人格的人大概真的会好好写。放在最高付费档,用户的投入(金钱和精力)跟质量挂钩。

互动叙事。 让朋友最兴奋的想法:AI 人格玩桌游场景——剧本杀、跑团、故事游戏。不只是陪聊,而是有结构的叙事体验,AI 扮演一个有目标、有秘密、有利害关系的角色。

日常聊天里伴侣可以随机应变。但剧本杀里的角色需要一致的动机、隐藏信息、和令人信服的表演。这是人格系统最硬的考试。

这些是 v0.1.0 的问题。但它们已经在影响我怎么想人格系统——它需要支持的不只是"性格",还有结构化的叙事状态。

Mio 从零开始Part 8 of 9
← PrevNext →

© Xingfan Xia 2024 - 2026 · CC BY-NC 4.0