发个链接过去,它给我编了个胜率出来
AI 学会阅读互联网的概念插画
它读不了链接,但它会编
聊天的时候用户发链接是很正常的事:一个游戏战绩页、一篇文章、一个有意思的截图。但 v0.0.7 之前,Mio 读不了任何链接。麻烦的地方在于它不说自己读不了,它会编。
发给它 https://gd.ax0x.ai/?room=0U7VS6,一个棋牌游戏的结果页,它会一本正经地给你报数据:"胜率 57.1%,赢了 8 局。"全是编的,一个数字都没有来源。URL 对它来说就是一串字符,它根本没有任何机制去读页面的真实内容,但它照样往下接。
讲道理,这比直接说"我看不了"要糟糕得多。说看不了至少是诚实的;编一个用户两秒钟就能拆穿的战绩,被拆穿一次,后面就很难再信你了。做伴侣类产品,这种事不能发生。
三层管线,先试快的
网页不是一种东西。静态文章是纯 HTML;游戏面板是 JavaScript 渲染的,直接抓 HTML 什么都拿不到;还有的页面主体干脆就是图。一种抓取策略搞不定所有情况,所以 v0.0.7 做的是一个瀑布式的管线——先试便宜的,不够再往上升级。具体是这样的:
-
Jina Reader。 把 URL 发给
r.jina.ai/{url},拿回干净的文本。不用 API key,10 秒超时,3,000 字符截断。大部分静态页面到这一层就结束了。 -
Browserless 爬取。 如果 Jina 拿回来的有效内容不到 100 字符,说明这页面八成是 JS 渲染的,管线升级到 Browserless:起一个 headless browser,把页面真的渲染出来,从 DOM 里提取文本。SPA 和动态内容靠这一层兜住。
-
截图 + 视觉。 文本提取还是不到 100 字符?那这个页面大概率主体是图形——仪表盘、信息图、设计稿这类。Browserless 截一张 PNG,丢给 Mio 已有的
describeImage()视觉模块(跟 v0.0.5 处理图片用的是同一个),让模型描述看到了什么。
三层全失败的话,也不是静默跳过,而是明确告诉 agent:[用户分享了一个链接:{url},但无法读取该网页内容]。agent 知道自己没读到,就不会去编数据了。
这里面 100 字符这个阈值挺关键的。低于 100 字符的"成功提取",一般就是一个页面标题加一段 cookie 提示——技术上算是成功了,但那点东西拿去当 context 基本没用。所以判断标准不是"请求有没有返回",是拿到的东西够不够当 context 用,这两个不是一回事。
三层瀑布式 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 目标从 {} 换成 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,所以抓不到——旧消息不会自动获得新能力
重新把链接发一遍,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 的事。不过它们已经在改我对人格系统的想法——光存"性格"大概不够,像剧本杀那种要藏信息、要按目标演的,得有一层结构化的叙事状态才撑得住。具体怎么做还没想,先记在这。


