AI

把性格测试做成一场冒险

RBTI 最早是一套传统性格测试:固定题目对应固定维度,答完以后从预设结果中挑出一只精灵。它足够稳定,却也有问卷产品的典型问题——用户很快就能看出每个选项在测什么,结果更像一次分数结算,而不是一段属于自己的经历。

现在的 RBTI 是一场发生在《洛克王国:世界》里的动态冒险。系统先随机选择一个真实副本,LLM 根据玩家每一次行动继续生成剧情;副本结束后,再读取完整旅程,从 50 只精灵中选出唯一的本命精灵,并生成一段契约判词。

项目源码:Bainianzzz/RBTI
在线体验:rbti-test.fun

这次改造真正改变的不是页面皮肤,而是产品的输入、状态、模型上下文和工程边界。它也让我更具体地理解了 vibe coding 里的 harness:Agent 负责生成和修改代码,harness 则负责给它轨道、仪表盘和护栏,让每一轮修改都能被约束、观察和验证。

它不是问卷,而是一段连续决策

玩家从首页签下契约,应用会从 16 个副本种子中随机选择一个舞台。副本名称和简介来自 BWIKI,模型可以在世界观内补充路线、机关、NPC 和战斗,但整局不能中途换地图,也不能引入其他作品或精灵白名单之外的精灵。

每一幕包含一段过场、一段情境和三到四个行动选项。玩家既可以选择预设行动,也可以输入自己的做法或心声。后者很重要:选项给流程提供稳定结构,自由输入则保留了问卷最容易丢失的意外答案。

这让性格不再通过“你认为自己是否勇敢”来申报,而是在连续行动中显现。有人会在危险面前反复保护同伴,有人喜欢先收集线索,也有人面对模型给出的所有路线都选择另辟蹊径。最终裁决看到的是这些具体瞬间,而不是几个抽象分数。

为了不让动态叙事变成无休止的聊天,流程还有明确节奏:从第 4 题开始,提示词要求剧情进入副本核心;当已完成 8 题时,Pinia store 会绕过模型的继续叙事意愿,直接进入裁决。前者是给模型的软约束,后者是应用代码里的硬上限。

一次完整旅程可以简化为:

  1. 玩家在首页签订契约,进入冒险页。
  2. 应用随机抽取一个副本种子,并请求模型生成第一幕。
  3. 页面流式展示过场、情境和行动选项。
  4. 玩家选择一个行动,也可以补充或完全使用自由输入。
  5. 应用保存本轮回答,模型把最新进展合并进滚动摘要。
  6. 如果副本目标尚未完成且没有达到题数上限,模型继续生成下一幕,流程回到第 3 步。
  7. 如果模型判断目标已经完成,或者应用触发 8 题硬上限,流程进入最终裁决。
  8. 模型读取完整旅程,从精灵库中选出本命精灵并生成契约判词。
  9. 结果页播放召唤仪式,随后揭示精灵卡片。

整个产品不要求登录,也没有数据库。副本、回答、摘要和裁决只保存在当前 Pinia 实例里;结果页被直接刷新时,内存状态消失,页面会引导用户重新开始。这既降低了实现复杂度,也意味着 RBTI 不是一个跨设备保存进度的账号产品。

架构:Nuxt 同时承载页面和模型网关

RBTI 当前使用 Nuxt 4、Vue 3、TypeScript、Pinia 和 Tailwind CSS 4。它是一个启用了全局 SSR 的 Nuxt 应用,而不是纯客户端 SPA。

SSR 在这里主要负责正常输出页面壳和首屏内容;真正的冒险请求必须等浏览器水合完成。app/pages/adventure/index.vueonMounted 中启动流程,避免服务端渲染阶段调用 LLM。结果页里的计时器同样只在客户端创建。这个边界同时解决了 SSR 兼容性和重复请求问题。

项目结构按职责拆成几层:

text
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 把生命周期显式划分成六种状态:

  1. idle:冒险尚未开始,等待初始化副本。
  2. generating:正在请求并流式接收下一幕剧情。
  3. answering:首个可见字段已经到达,页面展示剧情并等待玩家作答。
  4. concluding:副本目标完成或达到题数上限,正在生成最终裁决。
  5. done:裁决生成完成,可以跳转并展示结果。
  6. error:生成下一幕或最终裁决失败,等待玩家重试对应操作。

正常流程从 idle 依次进入 generatinganswering,随后在生成与作答之间循环;满足收尾条件后进入 concluding,最终到达 donegeneratingconcluding 发生异常时都可以进入 error

状态不仅决定渲染哪个组件,也决定哪些操作合法。流仍在传输时不能提交答案;没有选择也没有自由输入时不能继续;失败时记录的是 next 还是 conclude,重试会从对应断点恢复,而不是清空整段冒险。

两种 LLM 请求,两种上下文策略

叙事和裁决没有共用一条越来越长的聊天记录。

生成下一幕时,每次都是独立的 system + user 请求,只带上四类上下文:本局副本资料、允许出现的精灵白名单、上一轮滚动摘要,以及玩家最新完成的一幕。模型在同一个 JSON 响应中先给出更新后的 story_summary,再给出下一事件。摘要控制在 200 字以内,保存剧情进度、角色、线索和玩家表现出的性格倾向,成为后续叙事的唯一长期记忆。

生成裁决时,目标已经从“继续写故事”变成“寻找性格证据”。此时应用会重新展开全部已完成事件、选择和自由输入,再附上完整精灵契约库。模型必须返回一个 pet_id 和一段引用旅程细节的判词。

叙事需要压缩上下文,裁决需要完整证据。把它们拆成两个业务模块,比试图用一条万能 prompt 同时完成所有工作更容易调试。

流式 JSON 如何进入界面

浏览器不会直接访问 DeepSeek。它只向同源的 /api/llm 发送消息、温度、token 上限和输出格式;Nitro 路由从私有 runtimeConfig 读取 API Key,再调用 DeepSeek 的 OpenAI 兼容端点。

text
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 只允许 mainprod 分支触发部署,.vercelignore 则排除本地环境文件、构建产物、Playwright 记录和开发预览页面。公开仓库只保留 .env.example 中的配置契约,不保存真实 Key。

这条边界也应该进入 harness:Agent 可以修改客户端如何请求 /api/llm,却不能为了省事让客户端直接拿模型凭据;它可以启用 SSR,却必须保证 LLM 调用只在水合后的浏览器发生。部署配置不是项目完成以后的附属文件,而是架构约束的一部分。

最后

RBTI 表面上是在回答“哪只精灵最像你”,工程上处理的却是另一个问题:怎样把不确定的模型输出装进一个可控、可恢复、可以交付的产品流程。

产品用副本行动代替直白问卷;架构用两种上下文策略分别服务叙事和裁决;运行时用状态机、流式解析、白名单与硬上限控制模型;开发过程再用 AGENTS.md、目录边界、Playwright 和 review skill 组成 vibe coding harness。

我现在更愿意把 harness 理解成“给 Agent 的可执行环境”,而不只是一份提示词。提示词告诉它想做什么,harness 则持续回答另外三个问题:什么不能破坏,怎样知道已经做对,以及出错以后去哪里找原因。故事可以每次不同,但承载故事的轨道必须足够确定。