写给技术读者
服务的工作原理
MathTrail 位于一家人已经在用的聊天应用和家长的 Google 云端硬盘之间。每道题都由聊天模型来写,能不能送到孩子面前,则由服务决定。它是单一的程序:不调用任何自有模型,两次请求之间也不保留任何东西。
- Cloud Run 上的一个 Go 二进制文件
- 无状态
- 无数据库
- 不自行调用 LLM
- MCP 2026-07-28 · MCP Apps
聊天客户端1 · 客户端
- Claude
- ChatGPT
- 其他 MCP Apps 宿主
聊天模型出题,卡片把题目展示给孩子。
MCP · 通过 HTTPS 的工具调用
Google Cloud 上的 MathTrail2 · 运行时
Cloud Run · 一个 Go 二进制文件 · 无状态
核心:纯计算
- 规则
- 评分
- 检查
- 求解程序
- 内容
核心周边:OAuth、MCP 服务器、卡片、密封、限制。
Drive API v3 · 使用家长自己的令牌
Google 云端硬盘3 · 存储
MathTrail/mathtrail-profile.json
每个孩子一个文件,存放在家长自己的云端硬盘里:这是唯一长期保存的东西。
01
架构图
谁和谁通信。孩子和成年人都不直接访问 MathTrail,而是与聊天宿主交互。中间区块里的所有部分都属于同一个进程,而不是独立的服务。请选择任意一个部分,查看它的作用以及与之相连的部分。
02
一节课的逐次调用
模型和卡片调用同一组工具,依赖同一个事实来源:家长云端硬盘中的档案文件。要让课程继续下去,它们彼此之间无须传递任何信息。
出题
- 成年人
- 卡片
- 宿主的模型
- MathTrail
- 云端硬盘
1成年人 → 宿主的模型: “来一道新题”
2宿主的模型 → MathTrail: next_task
调用一开始,宿主就显示卡片:“正在准备下一道题…”
限制 · 规则选定专题、水平和难度
3MathTrail → 云端硬盘: 写入未完成的请求
4MathTrail → 宿主的模型: 请求已开启:请获取出题材料包
5宿主的模型 → MathTrail: get_package
6MathTrail → 宿主的模型: 出题要求 · 3道参考题 · 陷阱 · 模板 · 图的示例
模型写出题目、一个 Starlark 求解程序和一份自检
7宿主的模型 → MathTrail: submit_task:最多尝试3次
检查 · 求解程序必须恰好找到一个正确选项
8MathTrail → 云端硬盘: 这道题成为当前题目,答案已密封
9卡片 → MathTrail: read_task:卡片每4秒询问一次
10MathTrail → 卡片: 文字 · 图 · 五个选项 · 提示,不含答案
每次调用还会从云端硬盘读取档案,图中省略了这些读取。等待中的卡片永远看不到出题材料包:其中的参考题带着答案。在孩子解题时,模型会用 prepare_task 提前写好下一道题;当要求再来一道时,next_task 会立即把它放到一张新卡片上,不必经历这段等待。
作答
- 成年人
- 孩子
- 卡片
- 宿主的模型
- MathTrail
- 云端硬盘
1孩子 → 卡片: 点击选项C
2卡片 → MathTrail: submit_answer:不消耗聊天的回合
只记第一次作答 · 此时才打开密封 · θ 和 δ 只根据对错变动
3MathTrail → 云端硬盘: 写入评分和历史记录
4MathTrail → 卡片: 是否答对 · C背后的陷阱 · 解答图 · 步骤 · 评分(之前 → 之后)
卡片在原题的位置变成答案讲解
5卡片 → 宿主的模型: ui/update-model-context:一行字,随成年人的下一条消息一起读取
6成年人 → 宿主的模型: “为什么多了一根柱子?”
7宿主的模型 → 成年人: 就这道题本身回答,补充卡片的内容
按下选项就会记录答案,早于任何讲解,卡片也会立刻变成答案讲解。聊天不消耗回合,模型也不画任何东西。模型会随成年人的下一条消息读到卡片发来的那一行,回答关于这道题的问题时,不复述卡片上的内容。如果宿主发给模型的那一行丢失了,模型会在下一次调用时补上。
登录
- 家长的浏览器
- 宿主
- MathTrail
- 云端硬盘
1宿主 → MathTrail: POST /mcp,不带令牌
2MathTrail → 宿主: 401 · 元数据在哪里
3宿主 → 家长的浏览器: 打开 /oauth/authorize · PKCE S256
4家长的浏览器 → MathTrail: authorize
自己的同意屏幕:哪个客户端,以及它会把家长送回哪里
5家长的浏览器 → Google: 用 Google 账号登录 · 仅 drive.file
6Google → 家长的浏览器: 带着 Google 的授权代码返回
7家长的浏览器 → MathTrail: /oauth/callback
8MathTrail → Google: 交换授权代码
9MathTrail → 云端硬盘: /about:这是谁的云端硬盘?
用户 ID = 云端硬盘账号的 HMAC · Google 的令牌密封进我们的授权代码
10MathTrail → 家长的浏览器: 302 · code = mt1.c.…
11家长的浏览器 → 宿主: 交回授权代码
12宿主 → MathTrail: /oauth/token · 授权代码 + 代码验证器
13MathTrail → 宿主: 访问令牌15分钟 · 刷新令牌30天
家长只需登录一次。服务不保存这次交换的任何内容:请求放在一个已密封的 state 参数里传递,Google 的令牌则放在一个已密封的授权代码里。
当前题目的状态机
每一条“只能一次”的规则都是档案中的一个字段,在同一次读取、计算和写入之内检查。除此之外,没有任何东西会记住任何事。
nonenext_task出题要求已备好requestedsubmit_task ✓已密封,已发出issuedsubmit_answer只记录一次answered
requested ↺被拒,仍有尝试机会:每个请求最多三次requested → none被拒三次,或从请求开启起满15分钟;不消耗当天的题目数,三次被拒算作当天一次失败的请求issued → requested作答前按了“再来一题”:记为跳过,评分不变answered ↺之后的任何作答(来自第二个标签页或来自模型)都只会拿回第一次的结果,不写入任何内容answered → requested下一道题,出现在新卡片上;已作答的那道题不再是当前题目answered → issued下一道题已提前写好:next_task 立即把它放到新卡片上,跳过 requestedissued → none密封已无法打开:密钥已轮换两次;不记录任何内容
03
状态存放在哪里
有状态的服务器存进数据库的一切,在这里都由别人保管:聊天持有的已密封令牌、家长浏览器里的 Cookie,或家长自己的云端硬盘。无论重启服务,还是再启动一个副本,都不会丢失任何东西。
MathTrail 本身
无
没有账号,没有会话,没有注册信息,也没有 Google 令牌。每个请求都携带它所需要的一切。
聊天的令牌存储
已密封的令牌,对宿主不透明
- 访问令牌
- 15分钟
- 刷新令牌
- 30天,≤ 90天
里面密封着 Google 的令牌、一个用于限制的不透明用户 ID,以及家长登录时所在的国家。
家长的浏览器
两个 Cookie,没有会话
- 已批准的客户端
- 180天
- 登录防护(CSRF)
- 10分钟
丢失它们的代价是多看一次同意屏幕,或者重新登录一次。
家长的云端硬盘
唯一长期保存的东西
mathtrail-profile.json- 直到被删除
档案、评分和当前题目,其答案已密封。家长可以打开这个文件,云端硬盘也会保留它的版本记录。
Secret Manager
在实例启动时读取一次
- 密封密钥轮换后,之前的密钥仍会被接受
- 90天
- Google 的客户端密钥
- 固定版本
实例的内存
只有缓存:重启后丢失,也不会出任何问题
- 请求速率计数器
- 几分钟
- 档案文件的位置
- 10分钟
- 客户端的文档
- 5分钟~24小时
v1 中刻意省去的东西
- 数据库
- 队列
- 会话存储
- 共享缓存
- 现成的题库
- LLM API 客户端
- REST 和 OpenAPI
04
档案文件
一个孩子,一个 JSON 文件:它不是服务器上某个事实的缓存,而是事实本身。每个区块都有上限,每次写入都会守住,所以文件永远不会无限增长:通常的文件保持在目标之内,接近各项上限的文件则会超出目标。做过的题以指纹形式保存,而不是保存文本。
文件大小
≈ 61 KB / ≈ 79 KB
| 区块 | 内容 | 写入者 | 大小 | |
|---|---|---|---|---|
| 指纹 | 最多200个已发出题目的草图,用于近似重复检查;不含文本、专题和日期 | next_tasksubmit_task | 27 KB / 29 KB | |
| 评分历史 | θ 和该专题的 δ 在最近每次作答之前的值,以及在最近7个有作答的日子里,每天开始时 θ 和每个专题的值 | submit_answer | 14 KB / 15 KB | |
| 评分和专题 | θ 和孩子的起点;每个专题的 δ、作答、掉进过的陷阱、掌握情况及其水平 | next_tasksubmit_tasksubmit_answer | 8 KB / 14 KB | |
| 最近作答 | 最近20次作答和跳过,并且永远不少于规则要读取的5次作答 | next_tasksubmit_answer | 4 KB / 5 KB | |
| 当前题目(已密封) | 答案、每个错误选项背后的陷阱、解答和求解程序,合成一个不透明的字符串 | next_tasksubmit_tasksubmit_answer | 4 KB / 8 KB | |
| 当前题目(明文) | 卡片显示的内容:文字、图、五个选项、提示,以及作答之后的答案本身 | next_tasksubmit_tasksubmit_answer | 2 KB / 4 KB | |
| 未完成的请求 | 题目编写期间:它的 ID、出题要求、已用的尝试次数,以及是谁选的专题 | next_taskprepare_tasksubmit_task | 1.5 KB / 2 KB | |
| 服务与孩子 | 模式(schema)版本、一个随机 UUID、修订号;化名、年级、兴趣爱好、备注;当天的计数器 | save_profileedit_profile每次写入 | 0.6 KB / 1.5 KB |
提前写好的题目和当前题目放在一起,大小也差不多。文件中没有的内容:做过的题目的文本、孩子作答之前的明文答案、任何能识别家长身份的信息,以及等级(等级在显示时才计算)。云端硬盘不支持条件写入,因此保存前会重新读取一次文件,如果文件已经改变,写入就会被拒绝:两个标签页永远不会悄无声息地互相覆盖。
05
密封了什么,有效期多长
一个机制承担两项工作:把登录信息密封进聊天持有的令牌里,把题目的答案(当前题目的,以及提前写好的那道题的)密封进档案里。每个密封都带有它的用途,所以访问令牌无法冒充刷新令牌,已密封的答案也无法冒充其他任何东西。
拆开一个令牌
mt1.a.kQ7fWx.5rJ0Yb2mQ8vK1nT4pXc9…
- mt1
- 格式版本
- 用途
- c 授权代码 · a 访问 · r 刷新 · d 客户端 · s state · k 同意 · t 题目答案
- 密钥 ID
- 取自密钥本身,因此轮换时不会被察觉
- 密文
- XChaCha20-Poly1305,随机的192位 nonce
不是 JWT:除了服务本身,没有人会读取它;从一个不透明的字符串里,也不会有任何声明(claim)泄漏到日志中。
有效期,对数刻度
- 授权代码
60秒 - 进行中的登录
10分钟 - 访问令牌
15分钟 - 刷新令牌
30天,滑动延长,最长90天 - 密封密钥
每90天轮换一次 - 已批准的客户端
180天
无状态的服务器无法把授权代码标记为已使用,所以授权代码的有效时间窗口很短。轮换之后,之前的密钥仍会被接受,所以更换密钥时,令牌和已密封的答案都不会失效。