ENZH
和 AI 讨论这篇文章
ChatGPTClaude

发个链接过去,它给我编了个胜率出来

📊 幻灯片

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

它读不了链接,但它会编

聊天的时候用户发链接是很正常的事:一个游戏战绩页、一篇文章、一个有意思的截图。但 v0.0.7 之前,Mio 读不了任何链接。麻烦的地方在于它不说自己读不了,它会编。

发给它 https://gd.ax0x.ai/?room=0U7VS6,一个棋牌游戏的结果页,它会一本正经地给你报数据:"胜率 57.1%,赢了 8 局。"全是编的,一个数字都没有来源。URL 对它来说就是一串字符,它根本没有任何机制去读页面的真实内容,但它照样往下接。

讲道理,这比直接说"我看不了"要糟糕得多。说看不了至少是诚实的;编一个用户两秒钟就能拆穿的战绩,被拆穿一次,后面就很难再信你了。做伴侣类产品,这种事不能发生。

三层管线,先试快的

网页不是一种东西。静态文章是纯 HTML;游戏面板是 JavaScript 渲染的,直接抓 HTML 什么都拿不到;还有的页面主体干脆就是图。一种抓取策略搞不定所有情况,所以 v0.0.7 做的是一个瀑布式的管线——先试便宜的,不够再往上升级。具体是这样的:

  1. Jina Reader。 把 URL 发给 r.jina.ai/{url},拿回干净的文本。不用 API key,10 秒超时,3,000 字符截断。大部分静态页面到这一层就结束了。

  2. Browserless 爬取。 如果 Jina 拿回来的有效内容不到 100 字符,说明这页面八成是 JS 渲染的,管线升级到 Browserless:起一个 headless browser,把页面真的渲染出来,从 DOM 里提取文本。SPA 和动态内容靠这一层兜住。

  3. 截图 + 视觉。 文本提取还是不到 100 字符?那这个页面大概率主体是图形——仪表盘、信息图、设计稿这类。Browserless 截一张 PNG,丢给 Mio 已有的 describeImage() 视觉模块(跟 v0.0.5 处理图片用的是同一个),让模型描述看到了什么。

三层全失败的话,也不是静默跳过,而是明确告诉 agent:[用户分享了一个链接:{url},但无法读取该网页内容]。agent 知道自己没读到,就不会去编数据了。

这里面 100 字符这个阈值挺关键的。低于 100 字符的"成功提取",一般就是一个页面标题加一段 cookie 提示——技术上算是成功了,但那点东西拿去当 context 基本没用。所以判断标准不是"请求有没有返回",是拿到的东西够不够当 context 用,这两个不是一回事。

三层瀑布式 URL 浏览管线:先试便宜的 Jina 提取,提取内容不足 100 字符就逐级升级到 Browserless 爬取、截图+视觉,全失败则明确告诉 agent 读不了三层瀑布式 URL 浏览管线:先试便宜的 Jina 提取,提取内容不足 100 字符就逐级升级到 Browserless 爬取、截图+视觉,全失败则明确告诉 agent 读不了

Telegram 和 web 聊天都接上了

URL 浏览接在两条路径上:

  • Telegram:在 processBatch() 里跑,媒体处理之后、routeMessage() 之前——提取 URL、抓内容、追加进消息的 context。
  • Web 聊天:在 prepareChatContext() 里跑,resolveMedia() 之后。逻辑跟 Telegram 那边是同一套,就是入口不一样。

两边共用同一个 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——只有目标是函数才有。目标的类型决定了哪些 trap 可用,换句话说,包一个对象就只能当对象用,想被当函数调,目标本身就得是函数。

Proxy 目标的类型决定可用的陷阱:包一个空对象只有 get 陷阱,只能点号访问;把目标换成函数才有 apply 陷阱,才能被当函数(标签模板)调用Proxy 目标的类型决定可用的陷阱:包一个空对象只有 get 陷阱,只能点号访问;把目标换成函数才有 apply 陷阱,才能被当函数(标签模板)调用

修法就是把 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,
      )
    },
  }
)

这个 bug 把记忆检索和人格提取全打断了——所有用标签模板写的原始 SQL 查询全挂。它在出事之前完全隐形:点号访问跑了几个月一点事没有,直到有代码走了模板语法才炸出来。

还有个部署时序的乌龙

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

第一反应是部署没成功?查日志,部署是正常的。

真正的原因简单得多,也烦人得多:那条 URL 是凌晨 3:03 发的,在部署之前。我跟 agent 说"再试一次",重试的消息里只有"再试一次"这几个字,没有 URL,browseUrls() 自然什么都抓不到。旧消息不会自动获得新能力——这话说出来很显然,卡在里面的时候真想不到。

部署时序的乌龙:链接在部署上线之前发出,重试消息里只有'再试一次'没有 URL,所以抓不到——旧消息不会自动获得新能力部署时序的乌龙:链接在部署上线之前发出,重试消息里只有'再试一次'没有 URL,所以抓不到——旧消息不会自动获得新能力

重新把链接发一遍,Jina 返回了完整的 16 行游戏状态——玩家名、分数、炸弹序列、赢家,agent 描述得完全准确。功能从头到尾都是好的。

所以后面再遇到"新功能看起来不工作",我会先查一件事:触发它的那条输入,到底是部署之前来的还是之后来的。

23 个测试

browse.test.ts 覆盖了:

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

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

上线之后

再发链接过去,agent 拿到的就是页面的真实 context 了。战绩页能报出真的玩家和分数,读不了的页面它也知道自己没读到,会老实讲出来,不会再硬编。编数据这个事,用户拆穿了会丢信任,没拆穿的话拿着错的信息聊下去也好不到哪去,反正哪边都不行。

用户不用知道、也不用关心是哪一层搞定了他们的链接。发个 URL,拿到一个真实的回复。

跟朋友聊出来的几个想法

跟朋友聊 Mio,聊出来几个还没做、但已经在影响设计的想法,先记下来:

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

让用户从零写人格是个坑。 我本能的想法是开放自定义,让用户自己写。朋友一句话把我摁回来了:"99.9% 的人懒得写,写的人也会乱写。"写得差的人格比通用人格更破坏沉浸感——agent 没法维持一个内部矛盾、信息又不足的角色。更合理的做法是分层:

  • 大多数用户用模板——填空式的。不是现在这种整个预设,而是更细一档:选一个基础性格,自定义几个关键的关系事件,写几条重要记忆。结构够多,写不乱;自由度够,觉得是自己的。
  • 完全自定义放给高级用户——放在最高付费档。愿意花钱又愿意花精力写人格的人,大概率真的会好好写,投入跟质量是挂钩的。

互动叙事。 朋友最兴奋的是这个:让 AI 人格去玩剧本杀、跑团这类有结构的叙事游戏,扮演一个有目标、有秘密、有利害关系的角色。日常聊天里伴侣可以随机应变,剧本杀里的角色不行,要动机一致、藏得住信息、演得让人信。对人格系统来说,这个比日常陪聊难一个量级。

这些都是 v0.1.0 的事。不过它们已经在改我对人格系统的想法——光存"性格"大概不够,像剧本杀那种要藏信息、要按目标演的,得有一层结构化的叙事状态才撑得住。具体怎么做还没想,先记在这。

和 AI 讨论这篇文章
ChatGPTClaude

订阅更新

新文章发布时发到你的邮箱,不发别的。


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