把性格测试做成一场冒险
RBTI 最早是一套传统性格测试:固定题目对应固定维度,答完以后从预设结果中挑出一只精灵。它足够稳定,却也有问卷产品的典型问题——用户很快就能看出每个选项在测什么,结果更像一次分数结算,而不是一段属于自己的经历。
现在的 RBTI 是一场发生在《洛克王国:世界》里的动态冒险。系统先随机选择一个真实副本,LLM 根据玩家每一次行动继续生成剧情;副本结束后,再读取完整旅程,从 50 只精灵中选出唯一的本命精灵,并生成一段契约判词。
项目源码:Bainianzzz/RBTI
在线体验:rbti-test.fun
这次改造真正改变的不是页面皮肤,而是产品的输入、状态、模型上下文和工程边界。它也让我更具体地理解了 vibe coding 里的 harness:Agent 负责生成和修改代码,harness 则负责给它轨道、仪表盘和护栏,让每一轮修改都能被约束、观察和验证。
它不是问卷,而是一段连续决策
玩家从首页签下契约,应用会从 16 个副本种子中随机选择一个舞台。副本名称和简介来自 BWIKI,模型可以在世界观内补充路线、机关、NPC 和战斗,但整局不能中途换地图,也不能引入其他作品或精灵白名单之外的精灵。
每一幕包含一段过场、一段情境和三到四个行动选项。玩家既可以选择预设行动,也可以输入自己的做法或心声。后者很重要:选项给流程提供稳定结构,自由输入则保留了问卷最容易丢失的意外答案。
这让性格不再通过“你认为自己是否勇敢”来申报,而是在连续行动中显现。有人会在危险面前反复保护同伴,有人喜欢先收集线索,也有人面对模型给出的所有路线都选择另辟蹊径。最终裁决看到的是这些具体瞬间,而不是几个抽象分数。
为了不让动态叙事变成无休止的聊天,流程还有明确节奏:从第 4 题开始,提示词要求剧情进入副本核心;当已完成 8 题时,Pinia store 会绕过模型的继续叙事意愿,直接进入裁决。前者是给模型的软约束,后者是应用代码里的硬上限。
一次完整旅程可以简化为:
- 玩家在首页签订契约,进入冒险页。
- 应用随机抽取一个副本种子,并请求模型生成第一幕。
- 页面流式展示过场、情境和行动选项。
- 玩家选择一个行动,也可以补充或完全使用自由输入。
- 应用保存本轮回答,模型把最新进展合并进滚动摘要。
- 如果副本目标尚未完成且没有达到题数上限,模型继续生成下一幕,流程回到第 3 步。
- 如果模型判断目标已经完成,或者应用触发 8 题硬上限,流程进入最终裁决。
- 模型读取完整旅程,从精灵库中选出本命精灵并生成契约判词。
- 结果页播放召唤仪式,随后揭示精灵卡片。
整个产品不要求登录,也没有数据库。副本、回答、摘要和裁决只保存在当前 Pinia 实例里;结果页被直接刷新时,内存状态消失,页面会引导用户重新开始。这既降低了实现复杂度,也意味着 RBTI 不是一个跨设备保存进度的账号产品。
架构:Nuxt 同时承载页面和模型网关
RBTI 当前使用 Nuxt 4、Vue 3、TypeScript、Pinia 和 Tailwind CSS 4。它是一个启用了全局 SSR 的 Nuxt 应用,而不是纯客户端 SPA。
SSR 在这里主要负责正常输出页面壳和首屏内容;真正的冒险请求必须等浏览器水合完成。app/pages/adventure/index.vue 在 onMounted 中启动流程,避免服务端渲染阶段调用 LLM。结果页里的计时器同样只在客户端创建。这个边界同时解决了 SSR 兼容性和重复请求问题。
项目结构按职责拆成几层:
app/
├── pages/ # 首页、冒险页、结果页
│ └── */_components/ # 页面私有组件,不参与路由扫描
├── components/ # 全局共享的视觉组件
├── stores/adventure.ts # 冒险状态机与流程编排
├── lib/llm/ # prompt、SSE、叙事与裁决
├── lib/utils/ # 宽松 JSON 解析与通用工具
├── data/ # 16 个副本、50 只精灵及图片映射
└── types/ # 客户端业务类型
server/api/llm.post.ts # Nitro 模型代理
shared/types/llm.ts # 浏览器与服务端共享的请求契约
nuxt.config.ts # SSR、Pinia、Tailwind 与私有运行配置页面只做编排。冒险页根据当前阶段选择加载、作答、裁决或错误组件;真正的业务流程集中在 adventure store 中。store 把生命周期显式划分成六种状态:
idle:冒险尚未开始,等待初始化副本。generating:正在请求并流式接收下一幕剧情。answering:首个可见字段已经到达,页面展示剧情并等待玩家作答。concluding:副本目标完成或达到题数上限,正在生成最终裁决。done:裁决生成完成,可以跳转并展示结果。error:生成下一幕或最终裁决失败,等待玩家重试对应操作。
正常流程从 idle 依次进入 generating 和 answering,随后在生成与作答之间循环;满足收尾条件后进入 concluding,最终到达 done。generating 和 concluding 发生异常时都可以进入 error。
状态不仅决定渲染哪个组件,也决定哪些操作合法。流仍在传输时不能提交答案;没有选择也没有自由输入时不能继续;失败时记录的是 next 还是 conclude,重试会从对应断点恢复,而不是清空整段冒险。
两种 LLM 请求,两种上下文策略
叙事和裁决没有共用一条越来越长的聊天记录。
生成下一幕时,每次都是独立的 system + user 请求,只带上四类上下文:本局副本资料、允许出现的精灵白名单、上一轮滚动摘要,以及玩家最新完成的一幕。模型在同一个 JSON 响应中先给出更新后的 story_summary,再给出下一事件。摘要控制在 200 字以内,保存剧情进度、角色、线索和玩家表现出的性格倾向,成为后续叙事的唯一长期记忆。
生成裁决时,目标已经从“继续写故事”变成“寻找性格证据”。此时应用会重新展开全部已完成事件、选择和自由输入,再附上完整精灵契约库。模型必须返回一个 pet_id 和一段引用旅程细节的判词。
叙事需要压缩上下文,裁决需要完整证据。把它们拆成两个业务模块,比试图用一条万能 prompt 同时完成所有工作更容易调试。
流式 JSON 如何进入界面
浏览器不会直接访问 DeepSeek。它只向同源的 /api/llm 发送消息、温度、token 上限和输出格式;Nitro 路由从私有 runtimeConfig 读取 API Key,再调用 DeepSeek 的 OpenAI 兼容端点。
Browser
│ POST /api/llm
▼
Nitro Server Route
│ Authorization: Bearer <server-only key>
▼
DeepSeek API
│ OpenAI-compatible SSE
▼
Nitro 简化为 { delta }
│
▼
Fetch + ReadableStream → Pinia → Vue UI服务端把上游 SSE 简化成只包含 delta 的事件,并设置禁止代理缓冲的响应头。客户端逐段累加文本,再把当前完整字符串交给业务层。
这里不能简单地对每个 chunk 执行 JSON.parse,因为绝大多数 chunk 都只是尚未闭合的 JSON 片段。RBTI 写了两类增量提取器:一个读取字符串字段已经出现的部分,一个读取数组中已经闭合的选项。只要过场、正文或第一个选项出现,store 就从加载态切到答题卡;完整流结束后,再宽松移除 Markdown 围栏和额外文本,执行最终 JSON 解析。
这套实现让“流式”真正缩短了空白等待,而不只是拿到完整结果后播放打字动画。裁决也采用同一条流式通道,结果页可以先显示正在形成的精灵名和判词,再进入最终揭卡阶段。
不过护栏并不等于强类型保证。当前代码会检查 JSON 是否可解析、pet_id 和判词是否为空,并在空判词时重试一次;结果页也能识别不存在于本地精灵表的 ID。但项目还没有用 Zod 一类运行时 schema 对模型响应做完整校验,未知精灵的“回退展示”也没有真正选出替代精灵。这是当前架构仍需要补齐的边界,而不是 prompt 能彻底解决的问题。
Vibe coding 的 harness 是什么
RBTI 的 AI 冒险版主要通过一次较大的迁移 PR 完成:旧的 Vite 固定问卷、题库和 MBTI 映射被替换为 Nuxt、Nitro、动态叙事和新的精灵数据。之后又继续补上 SSR、项目级 Agent 规则、专用 PR review skill 和 Vercel 分支配置。
这段历史很有代表性。harness 并不是开工前就完整存在的脚手架,它是在 Agent 写出越来越多代码以后,为了解决上下文漂移、验证盲区和审查不一致而逐步长出来的。
1. 仓库规则提供稳定上下文
根目录的 AGENTS.md 记录了那些不能只靠聊天记住的约束:
- 全局 SSR,浏览器专属逻辑只能放进
onMounted或客户端分支; - API Key 只属于服务端,浏览器只能访问同源 API;
- Vue 统一使用
<script setup>和组合式写法; - 页面只做编排,私有组件进入
_components/,模块由index.ts暴露公共 API; - 视觉、跳转和 LLM 端到端改动需要通过 Playwright 在真实页面验证;
- 只有用户明确要求时,Agent 才能执行 Git 提交。
这些规则的价值不在于写得长,而在于每条都能转成具体动作。Agent 不需要在每轮对话里重新猜 SSR 边界、目录归属或验证方式,代码评审也有了共同基线。
2. 代码结构缩小每次修改的搜索空间
共享请求类型把浏览器与 Nitro 的协议放在同一个位置;llm/stream.ts 只负责传输,narrative.ts 负责故事上下文,verdict.ts 负责精灵匹配;Pinia store 负责状态迁移,页面负责渲染。
这也是 harness 的一部分。它把一次含糊的“模型输出有问题”拆成可以定位的几类故障:
| 现象 | 优先检查的位置 |
|---|---|
| API Key 或上游错误泄露 | Nitro 代理与运行配置 |
| SSE 丢字、粘包 | llm/stream.ts |
| 剧情偏离副本 | prompt 与 narrative.ts |
| 重复提交、错误重试 | adventure store |
| 结果 ID 无法展示 | verdict.ts 与精灵数据 |
| SSR 重复请求或 hydration 问题 | 页面生命周期 |
当职责边界足够清楚,Agent 修改一个模块时更容易沿真实调用链阅读上下游,也更不容易顺手重写无关代码。
3. 浏览器和 review skill 提供反馈
仓库约定用 Playwright CLI 验证视觉、交互、页面跳转和 LLM 完整链路,产物统一放进被 Git 忽略的 .playwright-cli/。结果页还允许使用不部署的本地预览路由,减少为了检查一张结果卡而重复消耗模型请求。
代码审查则被整理成仓库内的 pr-review-rbti skill。它要求 Reviewer 不只看 diff,还要沿调用链检查 SSR、水合、流取消、竞态、模型输入输出、密钥泄露、接口滥用和 Vercel 运行边界;验证结果必须区分 typecheck、build、PR checks 和 Playwright,没运行的项目不能写成通过。
这让另一个 Agent 接手 review 时,不必重新发明审查清单。对一个公开的 LLM 接口来说,超时、限流、请求体大小、错误信息和 prompt injection 都比代码格式更值得优先检查。
当前 harness 仍有明显缺口:package.json 没有 lint、test 或 typecheck script,原有 CI 也在 Nuxt 迁移时移除;类型检查要靠 npm exec nuxi typecheck 手动执行,真实 LLM 链路还依赖 API Key 和外部模型的非确定输出。换句话说,RBTI 已经有清晰的规则、结构和人工验证流程,但还不能把它描述成一套完善的自动化测试系统。
部署边界
RBTI 虽然没有数据库,仍然不能部署成一组纯静态文件。浏览器需要同源的 Nitro API 来隐藏密钥、拼装上游请求并转发 SSE,因此运行环境必须同时承载 Nuxt SSR 和服务端函数。
项目最终部署在 Vercel。仓库中的 vercel.json 只允许 main 和 prod 分支触发部署,.vercelignore 则排除本地环境文件、构建产物、Playwright 记录和开发预览页面。公开仓库只保留 .env.example 中的配置契约,不保存真实 Key。
这条边界也应该进入 harness:Agent 可以修改客户端如何请求 /api/llm,却不能为了省事让客户端直接拿模型凭据;它可以启用 SSR,却必须保证 LLM 调用只在水合后的浏览器发生。部署配置不是项目完成以后的附属文件,而是架构约束的一部分。
最后
RBTI 表面上是在回答“哪只精灵最像你”,工程上处理的却是另一个问题:怎样把不确定的模型输出装进一个可控、可恢复、可以交付的产品流程。
产品用副本行动代替直白问卷;架构用两种上下文策略分别服务叙事和裁决;运行时用状态机、流式解析、白名单与硬上限控制模型;开发过程再用 AGENTS.md、目录边界、Playwright 和 review skill 组成 vibe coding harness。
我现在更愿意把 harness 理解成“给 Agent 的可执行环境”,而不只是一份提示词。提示词告诉它想做什么,harness 则持续回答另外三个问题:什么不能破坏,怎样知道已经做对,以及出错以后去哪里找原因。故事可以每次不同,但承载故事的轨道必须足够确定。