做 AI 塔罗小程序时,我为什么不让大模型自己抽牌:一次上线复盘
我做了一款基于韦特体系的 AI 塔罗应用,有微信小程序和网页版两端,项目名是「星语塔罗丨AI塔罗解读」。它的流程是:程序洗牌,用户亲手选牌,AI 结合用户的问题生成解读,并支持围绕同一组牌继续追问。
这篇文章会介绍背后的四个设计决定,以及上线后遇到的两个问题。文中的数字来自我自己的测试和线上日志,样本有限;我也会交代每组数据能够说明什么、不能说明什么。
先问一个问题
做 AI 塔罗时,一种直接的做法是把问题交给大模型:「帮我抽三张牌,解读一下我该不该换工作。」模型可能会回答:「我为你抽到了星星正位……」
但模型说「我抽到了」,不代表它真的执行过抽牌。
如果模型没有接入抽牌工具,牌名和正逆位就只是它生成的文字。生成过程确实带有随机性,但这种随机性发生在选择下一个词时,会受到提问方式和训练数据等因素影响,并不等同于从 78 张牌中等概率抽取。
因此,我把流程拆成四步,让模型只负责最后一步:
- 程序洗牌,用户点选,确定抽到哪几张牌;
- 程序查出这几张牌的传统牌义;
- 用一份写好的规则告诉模型解读该怎么写;
- 模型结合用户的问题,完成解读。
第一步:洗牌交给程序
洗牌用的是 Fisher–Yates 算法,代码很短:
function shuffle<T>(arr: T[]): T[] {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
/** 洗全副 78 张,每张同时定好正逆位(各 50%)。 */
export function shuffleDeck(): DeckCard[] {
return shuffle(CARDS.map((card) => ({ card, reversed: Math.random() < 0.5 })));
}
洗牌完成后,78 张牌的顺序和正逆位就都确定了。用户看到一排牌背,点选后对应的牌才会翻开;点选本身不会再引入随机性。
最初,正逆位是在用户点选时才决定的。后来我把这一步提前到了洗牌时。两种实现的统计结果完全等价:每张牌正位和逆位的概率各占一半。我调整实现,是为了贴近实体塔罗的流程:牌在洗牌时就可能翻转,因此在用户伸手选牌之前,整副牌的顺序和朝向都已确定。小程序和网页版使用同一套逻辑。
有两点要说清楚:
- Fisher–Yates 的性质是:如果随机源均匀,每一种牌序出现的概率就相同。
- 我用的随机源是 Math.random(),它不是密码学安全的随机源。抽牌独立于模型,不代表结果不可预测。对一个塔罗应用来说这足够了,但我不会把它说成「绝对随机」。
为了检查实现,我用线上同一套函数进行了自检:模拟 10 万次「洗牌后取第一张」,统计每张牌出现的次数,并计算卡方统计量。理论上,每张牌应出现约 1282 次。我一共跑了三轮:
在自由度为 77、显著性水平为 0.05 时,参考阈值是 98.48;三轮结果都低于这个阈值。这说明这次检验没有发现明显偏离均匀分布的证据,但不能据此证明结果完全随机。
图 1:另一次 10 万次模拟的结果。这一轮的卡方统计量是 74.3,同样低于参考阈值。
第二步:牌义让程序查,不让模型背
同一张牌,在不同书籍和读者那里可能有不同解释。为了给解读提供统一参照,我整理了一份韦特体系牌义词库,涵盖 78 张牌;每张牌的正位和逆位各有五个关键词,以及一句简短释义。
用户抽到牌后,程序会取出对应词条,连同用户的问题一起交给模型。这就像查阅一份事先整理好的手册:程序根据牌和正逆位查找词条,而不是让模型临场回忆牌义。
我要求模型结合问题进行分析,而不是逐字复述词条。这有助于减少解读偏离主题的情况,但不能保证每次解读都准确。
图 2:一次三张牌阵的请求里交给模型的内容。高亮部分是程序从词库里加入的牌义参考。
第三步:把「怎么说话」写成规则
系统提示词相当于给模型的工作说明,我把它分为四部分:角色与语气、输出模板、风格要求,以及身份与边界。
输出模板会随牌阵变化。单张牌的解读分为三个部分:一句话定调、深度解读和行动建议。多张牌则分为五个部分:一句话定调、牌阵概览、逐张解读、整体洞察和行动建议。两种结构都先呈现核心观点,再展开牌面分析。
风格要求中的每条规则,都针对一种我希望减少的问题:
这些规则用于引导生成,并不意味着模型每次都会严格遵守;实际效果仍需持续检验。
第四步:模型与等待时间
我使用的是 DeepSeek Flash 系列。选型时,除了内容质量和成本,我最关注一个指标:用户多久能看到第一段文字。解读会边生成边展示;第一个字出现之前,用户只能等待。
这类模型会先「思考」再作答,思考时长会因设置而异。2026 年 9 月 8 日,我用同一段提示词和同一个模型做了一组对照,只调整推理强度这一个参数:
这组数据的样本量不大:low 测了 11 次,high 测了 3 次,不设置测了 2 次。它比较的是耗时,不能用于判断解读质量。基于这组结果,我决定在每个请求中显式设置推理强度,并采用 low。在这组测试里,low 的等待时间更集中,没有出现 high 那样的长尾。
上线后,我从日志中统计了 9 月 9 日至 10 月 12 日约一个月的首字等待时间:
图 3:线上首字等待的分布。
模型服务通过统一接口接入,因此更换供应商时不需要修改业务代码。我还在这层接口上实现了请求对冲:主线路请求发出 10 秒后仍未返回第一个字,就同时向备用线路发起请求;哪条线路先返回内容,就采用哪条,并取消另一个请求。
目前还无法判断请求对冲是否实际缩短了用户等待时间。日志中确实有备用线路先返回内容的记录,但无法区分这是它通过对冲更快返回,还是主线路发生错误所致。因此,我把它视为应对慢响应的备用机制,尚不把它当作已验证的提速手段。
踩过的两个坑
坑一:差点认定「推理长度控制不了」
上面的对照表是第二天才测出来的。前一天,我得出的结论正好相反。
当时我给请求加上推理强度参数,但测得的思考长度仍然忽长忽短。我一度认为这个模型的推理长度无法控制,准备放弃这个方向。
后来我发现,问题出在接入方式上。同一个模型提供两种格式的接口;我当时使用的是 Anthropic 兼容格式,而推理强度参数只在 OpenAI 格式接口上生效。在另一种格式下,这个参数会被静默忽略,既不报错,也没有提示。我设置的另一个参数——思考预算——也没有生效:预算设为 512,实际却用了 3035。第二天,我在该接口格式下做了测试,思考长度从 53 字到 18871 字不等,首字等待最长达到 144 秒。
换用 OpenAI 格式接口后,参数便开始生效。
这里有两点教训。第一,参数没有生效时,应先确认接口是否支持并接收了它,再判断功能是否可控。第二,默认行为也可能有较大波动:同一接口在未设置参数时,两次实测的思考长度分别是 89 字和 6476 字。
由于我提前用统一接口封装了模型服务,更换接口格式时没有改动业务代码。
坑二:一个链接组件,把访客锁在网页版一年
官网的设计是:电脑访问首页时先看到落地页,页面上提供「继续用网页版」链接,地址为 /?web=1。访客点击后,服务端会写入一个有效期一年的 Cookie;之后访问时,网站会直接进入网页版,不再显示落地页。
2026 年 8 月 8 日,线上出现了一个问题:电脑访客第一次打开网站时能看到落地页,但刷新页面或下次访问时,就会直接进入网页版,无法再看到落地页。
原因是链接使用了 Next.js 的 <Link> 组件。<Link> 默认会自动预取进入视口的链接,而预取请求也会经过服务端中间件。于是,访客还没有点击,浏览器就已经替他请求了 /?web=1,服务端因此写入了有效期一年的 Cookie。
这个问题在本地开发环境无法复现,因为 <Link> 的预取只在生产构建中启用。
我分三步修复了这个问题:
- 把链接从 <Link> 换成普通的 <a>。我尝试过在中间件中识别预取请求,但实测发现,Next.js 会在请求到达中间件前移除用于标识预取的请求头,因此中间件无法据此判断。换成 <a> 后,浏览器不会再自动预取这条链接。
- 中间件仍会识别浏览器原生预取、Safari 预览等请求,因为这些请求对应的请求头可以正常读取。
- 对于已经被误写入 Cookie 的访客,仅修复代码并不能立即恢复落地页显示;旧 Cookie 原本还会持续一年。我将服务端认可的 Cookie 值从 1 改为 2,让旧 Cookie 一次性失效。受影响的访客下次访问时会自动回到官网;此前主动选择网页版的访客只需重新点击一次。
这次问题让我再次确认:不要让 GET 请求产生副作用。预取、预览、爬虫和安全扫描等机制,都可能自动发起 GET 请求。
现在的样子
这套设计已经在线运行:程序负责洗牌,用户亲手选牌,词库提供参考,规则约束表达,模型完成解读。
最后说明:塔罗解读由 AI 生成,仅供自我觉察与娱乐参考,不构成专业建议。
本文由 AI图鉴自动抓取并翻译,版权归原作者所有;如需查看原文请点下方链接。
Why don't I let LLM draw his own cards when I'm doing the AI Taro applet: a rewinding drive.
I made an al-Taro application based on the Wet system, with both ends of the WeChat mini-program and the web-based version, the project called "Taro-Aitaro Interpretation." Its processes are: program shuffles, user handpicks, AI generates interpretation in relation to user questions, and supports continued questioning around the same deck.
The article will describe the four design decisions behind it, as well as the two problems encountered when they go online. The figures in the text come from my own tests and online logs, and sample is limited; I will also give each set of data what it says and what it doesn't.
Let me ask you a question.
When I was doing AI Taro, a direct approach was to give the question to LLM: "Show me three cards and explain whether I should change my job. The model might say, "I drew the stars for you..."
But the model says "I drew," which doesn't mean it did.
If the model does not have access to the draw tool, the name of the card and the reverse is the text it generates. The process of generation is indeed random, but this randomity occurs when the next word is selected, is influenced by factors such as the manner in which questions are asked and the training data, and is not equivalent to taking the medium probability from 78 cards.
So I broke the process into four steps, leaving the model only with the last step:
- Program shuffles, user selections, identification of which cards to draw;
- Programs to identify the traditional meanings of these cards;
- Tell the model how to write with a written rule;
- Models are read in conjunction with user problems.
Step 1: shuffle to program
The shuffle is based on Fisher-Yates algorithm, short code:
function shuffle<T>(arr: T[]): T[] {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
/ ** 78 copies are washed and each one is set in reverse (50 per cent each). */
export function shuffleDeck(): DeckCard[] {
return shuffle(CARDS.map((card) => ({ card, reversed: Math.random() < 0.5 })));
}
When the shuffle is completed, the order and reverse of the 78 cards are determined. When the user sees a back line, click the corresponding card before turning over; the dot itself will not introduce randomness.
Initially, the reverse was decided at the user point. Then I moved this step forward to the shuffle. The two statistical results achieved are fully equal: the probability of each card being equal to the probability of being equal and negative. I adjusted it to bring it closer to the process of the entity Taro: cards can be flipped when they are shuffled, so the order and direction of the whole deck is determined before the user reaches the hand to select the cards. The small program and the web version use the same logic.
There are two things to say:
- Fisher-Yates is of the nature that, if the random source is even, the probability of each series is the same.
- I'm using a random source, Math.Random. It's not a random source of password security. Draws are independent of models and do not mean that results are unpredictable. That's enough for a tarot application, but I'm not going to call it "absolute random."
To check that, I did a self-censorship with the same set of functions on the line: simulated 100,000 "smash and take the first one" and measured the number of times each card appeared and calculated the number of cards. In theory, each card should appear about 1282 times. I ran three rounds:
The reference threshold is 98.48 when the degree of freedom is 77 and the significant level is 0.05; all three rounds result below this threshold. This means that the test did not find evidence of a clear deviation from the even distribution, but it could not prove that the results were entirely random.
Figure 1: Results of another 100,000 simulations. The calculus for this round is 74.3, which is also below the reference threshold.
Step 2: Cards allow the program to check, not the model back
The same card may have different interpretations with different books and readers. In order to provide a uniform reference for interpretation, I have prepared a collection of the Wet System Thesaurus, which covers 78 cards; each card has five key words for real and negative, and a short interpretation.
When a user draws a card, the program takes the corresponding note and hand it over to the model along with the user's problems. It's like looking at a pre-packaged manual: The program searchs for phrases based on cards and reverses, rather than leaving the model in retrospect.
I asked that the model be analysed in the context of the problem, rather than rewording it verbatim. This helps to reduce the interpretation of deviations from the subject matter, but does not ensure that each interpretation is accurate.
Figure 2: The contents of a model presented in a request for three decks. Highlight is a card reference added from the vocabulary.
Step three: Write "How to talk" into a rule.
The system prompt corresponds to a job description for the model, and I divide it into four parts: role and tone, output template, style requirements, and identity and boundaries.
The output template changes with the deck. The interpretation of a single card is divided into three parts: a fixed interpretation, a deep reading and a proposal for action. The cards are divided into five parts: a set of words, an overview of the cards, a single reading, an overall insight and proposals for action. Both structures begin with a core view, followed by a card analysis.
Each rule in the style requirement addresses a problem that I wish to reduce:
The use of these rules to guide generation does not mean that the model is always strictly adhered to; the actual effects still need to be continuously tested.
Step 4: Models and waiting times
I use the DeepSeek Flash series. When selecting a model, I focus most on an indicator other than content quality and cost: how long can users see the first paragraph? The reading side generation side display; the user can wait until the first word appears.
These models are "thinking" before answering, and the time for thinking varies from one set to another. On 8 September 2026, I compared the same paragraph prompt to the same model, adjusting only the parameter of the reasoning strength:
The amount of sample in this group of data is not significant: Low measured 11 times, high measured 3 times, no set 2 times. It compares time-consuming and cannot be used to judge the quality of interpretation. Based on this set of results, I decided to clearly set the reasoning intensity in each request and to use the low. In this group of tests, the waiting time is more concentrated, without a long end like high.
When I went online, I counted September 9th to October 12th, about a month's waiting time:
Figure 3: Distribution of waiting lines.
Model services are accessible through a single interface, so that changes in business codes are not required when suppliers are replaced. I also made a hedge of requests on the interface: when the main line requested 10 seconds later had not returned the first word, the request was launched at the same time on the back-up line; the first line to return the content was the same and the other request was cancelled.
It is not yet possible to judge whether the application hedge actually reduces the user waiting time. It is true that there is a record of secondary lines returning content first, but it is not possible to distinguish between whether it returns faster through hedges or whether the main line is wrong. Therefore, I see it as a back-up mechanism for slow response and not as a proven speed boost.
Two holes.
Pit One: Almost concluded that "the length of the reasoning cannot be controlled."
The control table above was taken the next day. The day before, I arrived at the opposite.
At that time, I added parameters of reasoning to the request, but the length of thought measured was still short and short. At one point, I thought the length of the model's reasoning was out of control, and I was ready to give it up.
And then I found out that the problem was with access. The same model provides interfaces in two formats; I used the Anthropic compatible format at the time, and the reasoning strength parameter only works on the Open AI format interface. In another format, this parameter will be silently ignored, and will not be misreported or hinted. Another parameter I set up — thinking about the budget — did not work either: the budget was set at 512, but it was actually used at 3035. The next day, I tested it in this interface format, thinking about lengths ranging from 53 to 18871 words, with the first word waiting for up to 144 seconds.
The parameters are effective when the Open AI interface is replaced.
Here are two lessons. First, when the parameter is not effective, it should be determined whether the interface supports and receives it before the function is controlled. Second, default behaviour can also be highly volatile: When the same interface does not set parameters, the two measured thought lengths are 89 words and 6476 words, respectively.
Since I had sealed the model service in advance with the unified interface, the business code was not changed when the interface format was replaced.
Pipe II: A link component that locks visitors on the web page for a year
The official network is designed to see the landing page when the computer visits the first page, which provides a link to the "Continue with the Web Page" page at /?web=1. When a visitor hits, the server writes a one-year-valid Cookie; on subsequent visits, the website goes directly to the web page and does not display the landing page.
On August 8, 2026, there was a problem on the line: computer visitors could see the landing page when they first opened the site, but when the page was refreshed or next visited, they would go directly to the page version and no longer see the landing page.
This is because the link uses the <Link> component of Next.js. The default <Link> automatically prep access links to the viewer, and the prep request goes through the middle of the service. So the visitor had not clicked, and the browser had asked for /?web=1, and the service had written into Cookie, which was valid for one year.
This problem cannot be repeated in the local development environment because the pre-emption <Link> is only used in the production construction.
I fixed it in three steps:
- Replace the link from <Link> with the normal <a>. I tried to identify the pre-take request in the intermediate, but it was found that Next.js would remove the header for the pre-identify request before the request reached the intermediate, so that the intermediate could not be judged against it. After changing to <a>, the browser will not automatically prep this link.
- Intermediate items will still recognize requests such as the original browser pre-screening, the Safari preview, etc., as the corresponding requests are normally readable.
- For visitors who have been wrongly written to Cookie, repairing the code does not immediately restore the landing page; old Cookie would have lasted another year. I changed the number of cookies approved by the service from 1 to 2 to disable the old cookies once. Affected visitors will automatically return to the official web site on their next visit; visitors who have previously volunteered to choose a web version will only have to re-click once.
This question is for me to confirm: do not let the GET request have side effects. Mechanisms such as pre-take, preview, reptile and safe scanning may automatically launch the GET request.
The way it looks.
The design is already running online: the program is for shuffles, users select cards hand-in-hand, Thesaurus provides reference, rules binding expression, model interpretation.
Finally, the Tairo interpretation is produced by AI and is used only for self-awareness and recreation purposes and does not constitute professional advice.
Auto-fetched and translated by AI Atlas. All rights belong to the original author.