上一篇聊的是「什么时候讲故事」,这一篇聊「故事从哪儿来、怎么送到手机上」。听城本质上是个内容 App:西雅图 200 个点、芝加哥 100 个、London 50 个起步,一个月后长到 9 个城市包,温哥华、纽约、新泽西、湾区、大洛杉矶,还有上海。这几百个故事点和配套的几百张插画,生产和分发都得有一套自己的管道。

内容也是代码

这个项目内容侧只有一条总原则:内容也是代码。

story point 的 JSON、回声主题的 tags、插画的 registry,全部进 Git 仓库,Git 是唯一权威源。每次内容变更都要过 npm run validate-data:schema 校验、坐标范围、必填的来源字段,一样不能少。

这么做的好处和代码是一样的:内容改错了可以 revert,改动可以 diff review,而且 AI agent 可以放心地批量编辑内容,因为再怎么改也逃不过校验和版本控制。这条原则是后面一切的地基,下一篇要讲的内容自动化,全靠它兜底。

分发:修内容不发版

内容和 App 解耦的动机很直接:故事里改错一个年份,不应该重新发一个版本、再等一轮审核。

链路是这样的:validate-data 校验通过后,发布工具为 city pack、theme tags 和插画生成内容哈希,再上传到 Cloudflare R2。App 定期读取一份很小的发布清单,只下载发生变化的内容。

正文和图片都按内容哈希保存,发布过的文件不再原地改写。新内容通过校验以后,发布工具只需要更新清单里的引用,旧版本仍然留在原处。这省掉了主动清 CDN 缓存的步骤,也让回退有了明确的落点。

分发端只读取已经发布的资源,运行时不提供内容写接口。内容变更要经过独立的认证发布步骤,校验完成后才能更新清单。公开客户端只读,写操作留在发布流程里,这条边界足够清楚。

这里有个选型小插曲值得单独记一笔。R2 其实是 Fable 5 一开始帮我选的,理由是零 egress 费用,不存在账单爆炸路径。但我中途一度想换到 AWS S3 加 CloudFront,原因说出来有点丢人:纯粹是路径依赖,我之前建自己博客的时候用的就是 S3 加 CloudFront 这套给博客做 host,就自然而然觉得 App 的内容是不是也该用 AWS。真到自己算账的时候才发现,R2 有 10GB 免费存储、流量零 egress,效果实测也确实好,于是当天就改了回来,AWS 侧资源全删。教训:「熟悉」不是选型理由,实际账单才是。顺便承认,这一回 AI 的第一直觉比我的肌肉记忆靠谱。

故事点是怎么生产的

有朋友看完 App 后问我:这几百个地点到底从哪儿来的,你手里是不是有一份城市冷知识数据库?

我没有买过现成的地点数据,也没有把某张旅游景点表直接灌进 App。第一批内容的做法很粗暴:我给 AI 一座城市的范围和内容方向,让它从公开资料里找候选、写成初稿,再由我逐条决定能不能留。最早三座城的范围带着很强的个人目的:大西雅图的 200 个点覆盖我的日常生活圈,芝加哥的 100 个点服务五天旅行里的密集实测,London 先放 50 个种子点给准备去英国的爸妈。

候选地点从哪里来

第一批候选最依赖 Wikipedia 和 Wikidata。它们有稳定的地点名称、条目和基础事实,适合先把一座城市的故事骨架搭起来。西雅图还会查 HistoryLink、城市档案和市政府的历史建筑、公共艺术清单;London 会用 Historic England 和各区政府资料。Google Maps 和 OpenStreetMap 主要负责确认「这个东西还在不在、公众从哪里接近」,地图上的坐标和评论不能替代历史来源。

Atlas Obscura、Pokémon GO 这类点位列表也会拿来当雷达:看看我是不是漏掉了一个本地人熟悉的怪东西。它们只负责提供线索。后来做来源审计时,只有 Atlas Obscura 一个来源的点被我全部清掉,重点故事还要补一条 Wikipedia 之外的资料。

所以这些点不是从某个网站整包抓下来的。AI 负责把散落在百科、档案、地标名录和城市 open data 里的线索聚成候选池,我负责回到来源里确认故事有没有根据。

为什么留下这些点

位置触发 App 的选点标准和旅游榜单不一样。热门景点可以收,前提是故事要落在一个现场细节上;不知名的街角也可以收,只要它能解释这座城市为什么长成现在这样。

我会对每个候选问三个问题:人站在这里能看到什么?这段故事为什么必须在这里讲?用户会不会在通勤、散步或闲逛时自然经过?Fremont Troll 左手压着的真实甲壳虫可以指给人看;Denny Regrade 的故事要站在 Belltown 过分平直的街网里才有感觉。换成「西雅图有一座很高的塔」这种任何旅游手册都能写的话,分数就很低。

这三个问题后来变成了数据里的 look_forwhy_here,再加上一项「日常路过的可能性」。纯景点介绍、私人空间、必须专程绕路才成立的冷知识会被降级;现场没有痕迹、公开来源又撑不住的点直接删。

从候选清单到 city pack

最初那 350 个点,我真的是一条一条看过来的。这活儿没有想象中痛苦,因为故事本身挺好看:我作为西雅图本地人,有一些小故事和冷知识也不知道。审内容顺便涨知识,算是内容型项目的隐藏福利。

点位继续增加以后,直接生成正式 JSON 已经不够稳。6 月 22 日扩湾区和大洛杉矶时,我先让 AI 交一张候选表:地点属于哪条城市主线,现场看什么,为什么是这里,来源是什么,内容有没有编辑风险。我在表里标「选用、弃选、待议」,只有选用的点才进入 city pack。这样可以在写几百字之前先砍掉弱点,也能看出一批候选是不是全挤在市中心、全是建筑,或者只是为了补地图空白硬凑数。

大家最担心的「AI 编造故事、编造来源」,在这个项目里没有成批出现,因为 prompt 里写死了一条:每一则故事都要带公开可查证的来源。来源链接不等于事实自动正确,但它迫使初稿留下核查入口,也让后面的审计有东西可追。

真正的重灾区是坐标。三个印象最深的案例:

  1. Bellevue 的 Larsen Lake 蓝莓农场,坐标经常莫名其妙偏出去几个 mile,属于一眼假;
  2. 西雅图机场的几个点,标是标在机场没错,但标在了跑道上。你不坐飞机经过不了那个点,坐了飞机也未必经过,跑道离航站楼老远了;
  3. 最有意思的是芝加哥 ORD:那个机场有好几个航站楼,AI 把点标在其中一个上。人到了机场、进了另一个航站楼,这个点永远不会触发。

第三个案例直接催生了一个功能:一个地点、多个触发点。大型机场、公园、校园这类地方,用两到六个触发点覆盖主要入口和动线,而不是指望一个坐标点包打天下。坑变成功能,这是整个项目里我最喜欢的一类时刻。

坐标的防线最后也程序化了:geo-audit.jscandidate-geo-precheck.js 负责机器核查,人负责抽查,AI 生成的坐标不再有免检待遇。

顺便说说上海,九个城市包里唯一的中国城市。选它是因为上海算国内比较开放、比较潮流的城市,citywalk 的人多,而且我有同学朋友在上海工作生活,可以直接抓来试用。还有一点是上海在互联网上公开可查的资料比较齐全,国内现在和其他地方不太一样,很多资料都在私域,来源可能是自媒体或者个人,可信度会打一个折扣。

最花人力的环节:去 AI 味

如果你问这条内容管道里人力花得最多的环节是哪个,答案既不是选点也不是查坐标,是改稿。

city pack 生成之后,我经常一读就皱眉:故事写得太 AI 化了,AI 味特别浓。有的无聊,有的用词一眼就是模型腔。于是就一轮一轮地改语句、改措辞,轮流改了几轮之后,才会觉得这个故事看起来好一点。

我对这件事的立场很简单:这些故事是给人看的,不是给 AI 看的,以人的体验优先。管道可以解决「有没有」,只有人能负责「好不好」。改出来的经验也没浪费,最后都反哺回了选点 skill 里的中文文案规范,让下一批生成的初稿离能用更近一点。

插画:AI 线稿的分寸感

每个故事点配一张插画,风格是统一的:黑白松散线稿,城市漫游者随手速写风,暖纸白背景。

Anchorage 城市包 42 张黑白暖纸线稿地点插画的验收拼图

后来为 Anchorage 做的 42 张 canonical 定稿,正好能看清这套批量生产方法:统一画幅和纸色,每一格仍要守住各自地点的事实结构。

这个风格试了很多轮才定下来。生成图片的 prompt 改了一遍又一遍,主要毛病是 AI 太过于纠细节,画得太细太满,而我想要的是比较随意的速写感。松弛感这个东西,恰恰是要反复调教才能得到的。

为什么不直接用照片?三层理由。第一层是定位:照片强调的是地点本身,用照片就又滑回导游 App 和地图 App 的赛道了,速写更能表达「故事」这个层面的东西。第二层非常实际,版权:我这些地点的照片素材从哪儿来?扒 Google Maps 上的现有素材算不算侵权?从别处收图又算不算?线稿插画可以减轻这个问题。第三层是层次:彩色留给了城刊封面,地点插画保持黑白,两层视觉正好拉开。

对 AI 插画这件事本身,我也刻意保持了分寸。产品文档里写得很清楚:AI 插图定位为 MVP 阶段的临时视觉资产,不作为品牌卖点。分享卡角落里那行小字 “AI-generated illustration”,就是这个边界的落地。长期设想里,AI 图承担「事实写生层」,画地点长什么样;如果哪天真有艺术家或者 urban sketcher 社区愿意投稿,人的画承担「记忆解释层」。这个社区投稿往UGC方向发展的想法我认真考虑过,后来觉得维护社区的成本太高,暂时搁置了。

工程侧还有个减重设计:发布工具确认 R2 上的 canonical PNG 与本地 sha256 一致后,才把原图送入经过校验的离线归档。registry 记录每张图处于待上传、已归档还是缺失状态;条件不满足时,工具会拒绝归档,免得误删尚未上传的图。350 张插画走完这套流程,仓库不用背着整套图片。

内容红线

内容管道里还埋着一层不太显眼但很重要的东西:每个故事点都带一个 editorial_risk 风险等级,R0 到 R4,高风险内容默认隐藏。

这套分级不是被某个具体事故逼出来的,是提前想的。一方面 App Store 本身有年龄分级和内容分级的要求,我自然会想到故事里会不会有不适合小孩看的内容。另一方面是地缘现实:每个地方的政治环境、历史叙事都不一样,涉及政治、商业的内容最好先分级、该默认藏的默认藏,不然之后处理起来会很麻烦。说得再具体一点:AI 选点是不管意识形态的,万一它兴致勃勃地帮你选了旧金山城里某个政治高度敏感的纪念雕像,将来某个审核一看到,你就说不清楚了。

所以风险控制做在了数据 schema 里,而不是审核流程里:每个点生产出来就带着风险标签,比事后人肉巡查可靠得多。候选阶段先判断风险,写正文时才知道哪些事实需要多一条来源、哪些角度应该直接放弃。

城刊与回声:策展层

散点之上还有两层结构:单城市的「城刊」(storylines,把相关的故事组成一本本小刊)和跨城市的「回声」(同一个主题在不同城市的回响,比如「被填掉的河」这种)。

听城城刊页面,展示西雅图的彩色城刊封面与故事进度

城刊页里的彩色封面负责组织阅读层次,地点插画仍保持黑白。图为 App Store 素材使用的真机源图。

策展是内容产品真正的差异化,也是我盯得最紧的环节。组什么主题、哪些点够格进回声、城刊叫什么名字,这些是编辑判断,不是生成任务。规则里有一条写死的:城刊封面必须我亲自过目确认,才能定稿发布。AI 可以提案,拍板的必须是人。

接下来

这条管道的验收标准当初定的是「内容 OTA 全链路跑通一次真实修复」。后来确实靠它修过线上的文案和插画,一个版本都没发。改一个字,跑一遍发布脚本,用户下次打开 App 就是新的了。

但手动跑发布脚本仍然是人肉环节。下一篇讲怎么把这整条流程交给机器人:GitHub Actions 加 Codex 组成的「城市许愿」流水线,用户提个 issue,机器人自己选点、写稿、开 PR。