AI 问答游戏 — 提示词、结构化输出,和状态机
AI 问答游戏 — 提示词、结构化输出,和状态机
这一章讲三件事: 怎么在 Python 里调用大语言模型(LLM);怎么让它的回答 能被程序可靠地解析;以及——又一个执行模型问题——一个有先后顺序的游戏, 怎么在「每次点击都从头重跑」的框架里不走乱。 读完你会写第一个真正的 AI 应用,并且拿到三个可复用的模式: 提示词外置、结构化输出、显式状态机。
1. 这一章讲什么
应用是 Fact Frenzy 问答游戏:AI 出题、玩家自由文本作答、AI 判分, 还能模仿 Alex Trebek、Eminem、Gollum 等风格主持1。选它做第一个 AI 应用, 是因为 LLM 在这里身兼三职,正好把「提示词驱动开发」的三个面向全过一遍: 生成(出题)、判断(判分)、风格化(主持)2。
LLM 一句话定义:读过海量文本、能处理也能生成人类语言的人工智能系统; OpenAI、Anthropic、Google 这几家的当家产品是主流代表3。 书里也提前打了预防针:LLM 不是全知的,会犯错(后面叫「幻觉」), 问答游戏正好是能容忍偶尔出错的好场景4。
2. 顶层全景:一次问答的完整旅程
玩家点「新游戏」
│ Game 对象入 session_state(它是「活」的,要跨轮存活)
▼
GET_QUESTION ──调 LLM 出题──▶ ASK_QUESTION ──玩家提交──▶ EVALUATE_ANSWER ──调 LLM 判分──▶ STATE_RESULT
▲ │
└────────────────「下一题」按钮────────────────────────────────────────────────────────────┘
(没玩够 5 题就循环;玩够了 → Game over + 总分)
图说:四个状态一个环。每换一次状态 st.rerun 一次;界面根据当前状态画不同部件。主线例子:玩家答「Ottawa」,这题恰好是「加拿大首都是哪」——但第一版应用会 判错,说 Ottawa 不是太阳系最大行星的答案。这个 bug 是本章的转折点, 它把我们从「会调 API」逼到「要管状态」5。
3. 核心原理(一):调用 LLM——消息、角色、补全
装 openai 库,三行起步6:
>>> from openai import OpenAI
>>> client = OpenAI(api_key='sk-proj-...')
>>> messages = [
... {'role': 'system', 'content': 'You are a helpful programming assistant'},
... {'role': 'user', 'content': 'Explain what Streamlit is in 10 words or fewer'}
... ]
>>> completion = client.chat.completions.create(model='gpt-5.1', messages=messages)
>>> completion.choices[0].message.content
'Streamlit is a framework for building interactive web applications easily.'三个构件:
- prompt(提示词) = 一列 message(消息),每条消息是
{role, content}字典7。 - role(角色) 三种:system 是给模型的行为指令(你是主持人);user 是人的输入; assistant 是模型的回答(下一章的聊天会用到它)8。
- chat completions(聊天补全)端点:给它一段对话,它把「接下来该说的」补全—— 所以叫 completion9。模型名(gpt-5.1)是发布当时的快照,书里特意提醒: 模型换代极快,用前去定价页挑当期最合适的10。
封装成 Llm 类(和第 08 章 Database 类同一个套路):
__init__ 存客户端,ask(user_msg, sys_msg=None, schema=None) 是对外的唯一入口11。
API key 照旧进 secrets.toml,这次是最简形式:文件里就一行 llm_api_key = "sk-..."12。
4. 核心原理(二):结构化输出——让回答可以被程序读
问题。 判分要拿到两样东西:这题对不对、正确答案是什么。如果让模型自由作答, 它可能说「Incorrect, the answer is actually …」,也可能说「Hah! It would have been correct if…」——人读着都懂,程序没法稳定解析13。
解法:Structured Outputs。 你给一个模式(schema),模型保证按模式产出。
模式用 pydantic 库写——pydantic 是数据校验库,BaseModel 是它的基类
(继承:子类自动获得父类的校验、序列化能力,不用从零写)14:
from pydantic import BaseModel
class AnswerEvaluation(BaseModel):
is_correct: bool
correct_answer: str写法看起来和 dataclass 一样,差别在底座:BaseModel 带真实的数据校验。
Llm.ask 加第三个参数 schema:传了就走另一个 API
(beta.chat.completions.parse,beta 表示接口还可能有变),传 response_format=模式类
(注意传的是类本身,不是实例——Python 里类也是对象,可以当参数传来传去),
返回 message.parsed——一个填好字段的 AnswerEvaluation 实例15。
之后判分就是普通属性访问:evaluation.is_correct 决定加分,
evaluation.correct_answer 展示答案16。
配套习惯:提示词外置。 所有提示词集中放 prompts.py,每个是
{'system': …, 'user': …} 字典,需要变量就留 {question}、{answer} 占位,
用前 .replace() 填充17。理由:提示词和代码是两种「活体」,
改提示词不该碰代码文件——而且你会反复改(下一节细说)。
5. 核心原理(三):状态机——顺序应用在第 04 章执行模型上的解法
第一版接入判分后出现诡异 bug:玩家对「加拿大首都」答 Ottawa,
提交后题目却变成了「太阳系最大行星」,Ottawa 被判错18。
机制:点 Submit → 脚本从头重跑 → ask_llm_for_question() 又调了一次 LLM、
出了一道新题 → 拿新题去判旧答案。
解法是显式状态机。 Game 类增加 self.status 属性和三个配套属性
(curr_question / curr_answer / curr_eval),状态转换只由三个方法完成:
obtain_question(GET_QUESTION → ASK_QUESTION)、
accept_answer(→ EVALUATE_ANSWER)、evaluate_answer(→ STATE_RESULT);
前端按 status 分支画界面,每步结束 st.rerun() 进入下一个分支19。
Game 实例整个放进 session_state——自定义对象可以进 session_state,
这正是状态机能活下来的前提20。
长操作配 st.spinner(转圈状态元素;家族里还有 st.status、st.toast、st.progress)21。
分数、题数随之加上:答对 +1;is_over() 判断已答题数 ≥ max_questions;
「新游戏」按钮其实就是 new 一个 Game 盖掉 session_state 里的旧实例22。
button 的两种写法在这里正面相遇:if st.button(...)(点击 → 重跑 →
这轮里 if 为真 → 手动 rerun)和 on_click=回调(回调在重跑之前执行,
免手动 rerun,但回调里不许再触发 rerun)23。前者好懂,后者省事;
本章「下一题」用后者,是两种风格的一次对照教学。
6. 核心原理(四):问题的多样性——温度、记忆、种子
多题游戏里第一个现实问题:同一个提示词,模型总爱出同一道题24。 背后是 LLM 的本质:它是按概率办事的——同样输入,输出会按一套概率 来回变动;普通代码则不然,同样输入必然同样输出。书里给了四层解法,一层比一层工程化:
- temperature(0–2)与 top_p(0–1):两个旋钮调「下一个 token 的概率分布」。 temperature 越高,概率曲线越平,冷门 token 越可能中选,回答越发散; top_p 是累计概率的截断线——例:候选概率 but 0.6、then 0.2、a 0.1、this 0.06, top_p=0.8 就只从 but、then 里选;top_p=0.95 才放 a、this 进来。 出题要多样性:temperature 0.7–0.8、top_p 0.925。 (注意:推理型模型不支持这两个参数,书里专门提醒26。)
- 把已问过的题写进提示词:
{already_asked}占位,换行拼接注入—— 同一局绝不重题,这是硬保证27。 - 让模型先想类目和主题——自己拓宽选择空间,比人维护类目表省事且更多样28。
- 随机种子:
seed = int(time.time())(当前时间戳,天然每次不同)塞进提示词; 书里诚实标注:说不清原理,但实测增加了多样性29。
一个要内化的警告:调整提示词不是纯科学,是反复实验(prompt engineering 这行 因此存在)——连「让模型一步步想」都能提升表现,这是实验发现,不是定理30。
成本侧栏(值得整段带走):LLM 按 token(词或词片段)计费,输入输出都算; gpt-5.1 当时 $1.25 / 百万 token。省钱的杠杆:提示词写短、输出限长(max_tokens)、 合并请求(一次生成全部题目)、便宜模型干简单活、极端情况让用户自带 key31。 作者自曝写本章全部测试花了不到 15 美分——量级感比数字本身重要32。
7. 核心原理(五):让设置成为界面——st.data_editor 与角色注入
最后一步:把「主持人风格」「难度」从代码常量变成玩家可改的设置。 新部件两枚:
- st.data_editor:可编辑表格,吃 dataframe 也吃原生字典;
column_config的SelectboxColumn把列变成下拉(选项 + required);num_rows='fixed'禁止加行;一定要给 key(无 key 的部件某轮没渲染会丢值)33。 - st.popover:点击弹出的小浮层,with 块里是内容——设置藏在里面不占地方34。
接入方式:Game.__init__ 收 settings 字典;get_setting('Quizmaster')
抹平「值是单元素列表」的丑细节;提示词里加 {quizmaster}、{difficulty} 占位,
每轮回填;每次 rerun 调 modify_settings(settings) 让改动即时生效35。
至此出题提示词长这样:「你是模仿 {主持人} 说话风格、从不出重复题的主持人;
先想一个独特类目和其中的主题,最后用随机种子 {seed} 出一道 {难度} 的独特问题,
已问过:{已问列表}」——一段自然语言,同时是产品需求的完整描述。
这就是提示词驱动开发的本来面目。
8. 作者的判断与证据
判断:LLM 三职一身是这类应用的最佳入门形状。 出题/判分/风格化恰好覆盖 「生成、解析、控制」三个工程侧面,证据是本章结构本身36。
判断:提示词与代码分文件。 依据是「你会反复改提示词」37——经验之谈, 我们完全认可,并补一句:提示词也是资产,值得像代码一样进版本控制、做 A/B。
判断:种子注入「不清楚为什么有效但实测有效」。 书里罕见的「诚实的不确定」, 比编一个机制故事可贵38。
9. 边界与局限
- 模型名、价格、beta 接口全是时点快照(gpt-5.1、beta.chat.completions、 $1.25/百万 token);本章代码照抄前要对当期文档。
- 幻觉未解决:书里明说我们可能生成事实错误的题或答案,只是实测频率不高; 治本(接外部知识源)留到第 10 章39。
- 状态机是单用户单机心智:两个标签页各玩各的没问题,但并发、断线恢复、 防重复提交都不在模型内。
- 结构化输出的 beta 前缀意味着接口可能变;而且它对「输出必须是严格 JSON 模式」 的要求,某些复杂模式会失败——本章的简单两字段模式没问题。
- 成本侧栏的策略里,「合并请求一次生成全部题目」与「逐题生成以利用已问列表」 存在张力,书里并列给出但没帮读者选——实际取舍取决于你对重复题的容忍度。
判断(我们的,不是书里的): 本章最有迁移价值的不是任何代码,而是那个 显式状态机模式:「状态属性 + 转换方法 + 前端分支 + 每步 rerun」, 它是「执行模型 = 全脚本重跑」这个约束下,一切顺序型应用(向导、表单流、 多轮对话)的通用骨架。第二个是提示词外置——它其实是「配置驱动设计」 (第 03、06 章)在 LLM 时代的直系后代:产品行为越来越多地写在自然语言文件里, 这正在改变「编程」的边界。 如果错,会错在: 状态机模式在应用变复杂后会退化成「状态字符串地狱」 (十个状态的转换表没人记得全);社区的趋势是用 st.fragment 或会话内事件队列 替代手写状态机。本章的模式是正确起点,不是终点。
10. 可带走的
- 调 LLM = 组 messages(system 指令 + user 输入)→ chat.completions → 取 choices[0].message.content。
- 让程序读模型回答,用 Structured Outputs:pydantic BaseModel 定义模式,response_format 传类本身。
- 提示词全部外置一个文件;留占位符,代码侧 replace 填充;你会反复改它。
- 顺序型应用:状态属性 + 转换方法 + 前端 if/elif + 每步 st.rerun;有状态的自定义对象整体放 session_state。
- 长操作配 st.spinner;状态元素家族还有 st.status、st.toast、st.progress。
- temperature 拉平概率曲线(越发散),top_p 画累计概率截断线;出题 0.7/0.9 起步。
- 同局不重题 = 已问列表注进提示词;跨局多样 = 类目/主题让模型自选 + 时间戳种子。
- button 两种写法:if 式(易懂)vs on_click 回调(免手动 rerun、回调里不能 rerun)。
- LLM 按 token 双向计费;省钱的四杠杆:短提示、限输出、合并请求、换便宜模型。
- st.data_editor 把字典/表格变可编辑界面,column_config 定列类型;记得给 key。
- 设置进提示词:产品行为写在自然语言里,代码只负责填空。
- 幻觉客观存在;「实测频率不高」不是工程保证,接外部数据源才是(下一章)。
11. 原文地图
| 主题 | 原书章 | 原文位置 |
|---|---|---|
| 概念、需求与三职论点 | 9 Creating an AI-powered application | text/18-ch09-9-creating-an-ai-powered-application.txt:178搜「triple role」) |
| LLM 定义与能力边界(会犯错) | 9 Creating an AI-powered application | text/18-ch09-9-creating-an-ai-powered-application.txt:183搜「not omniscient」) |
| 消息与三种 role | 9 Creating an AI-powered application | text/18-ch09-9-creating-an-ai-powered-application.txt:245搜「Each request to the LLM」) |
| chat completions 端点与补全 | 9 Creating an AI-powered application | text/18-ch09-9-creating-an-ai-powered-application.txt:266搜「comes next」) |
| Llm 类封装 | 9 Creating an AI-powered application | text/18-ch09-9-creating-an-ai-powered-application.txt:290搜「Llm class」) |
| secrets.toml 存 key | 9 Creating an AI-powered application | text/18-ch09-9-creating-an-ai-powered-application.txt:372搜「secrets.toml」) |
| 解析难题与「创造性回答」 | 9 Creating an AI-powered application | text/18-ch09-9-creating-an-ai-powered-application.txt:434搜「tons of creative ways」) |
| Structured Outputs 与 pydantic | 9 Creating an AI-powered application | text/18-ch09-9-creating-an-ai-powered-application.txt:440搜「Structured Outputs」) |
| beta.chat.completions.parse 与传类本身 | 9 Creating an AI-powered application | text/18-ch09-9-creating-an-ai-powered-application.txt:491搜「response_format」) |
| 提示词外置 prompts.py | 9 Creating an AI-powered application | text/18-ch09-9-creating-an-ai-powered-application.txt:522搜「prompts.py」) |
| Ottawa/Jupiter 错配 bug | 9 Creating an AI-powered application | text/18-ch09-9-creating-an-ai-powered-application.txt:629搜「shenanigans」) |
| 状态机设计(四状态图) | 9 Creating an AI-powered application | text/18-ch09-9-creating-an-ai-powered-application.txt:651搜「sequential」) |
| Game 类 status 与三个转换方法 | 9 Creating an AI-powered application | text/18-ch09-9-creating-an-ai-powered-application.txt:667搜「Moving through game states」) |
| 前端按状态分支 + spinner + rerun | 9 Creating an AI-powered application | text/18-ch09-9-creating-an-ai-powered-application.txt:813搜「conditional branching」) |
| 计分、题数、Game Over | 9 Creating an AI-powered application | text/18-ch09-9-creating-an-ai-powered-application.txt:847搜「Keeping score」) |
| 概率性、幻觉注记 | 9 Creating an AI-powered application | text/18-ch09-9-creating-an-ai-powered-application.txt:1066搜「black box」) |
| temperature 与 top_p 机制 | 9 Creating an AI-powered application | text/18-ch09-9-creating-an-ai-powered-application.txt:1108搜「probability distribution」) |
| 多样性四招(已问列表/类目主题/种子) | 9 Creating an AI-powered application | text/18-ch09-9-creating-an-ai-powered-application.txt:1170搜「Including previous questions」) |
| 新提示词与循环状态图 | 9 Creating an AI-powered application | text/18-ch09-9-creating-an-ai-powered-application.txt:1270搜「never asks the same question twice」) |
| on_click 回调与 if 式对比 | 9 Creating an AI-powered application | text/18-ch09-9-creating-an-ai-powered-application.txt:1419搜「on_click parameter」) |
| 成本侧栏(token 计费与优化) | 9 Creating an AI-powered application | text/18-ch09-9-creating-an-ai-powered-application.txt:1448搜「Cost considerations」) |
| st.data_editor 与 column_config | 9 Creating an AI-powered application | text/18-ch09-9-creating-an-ai-powered-application.txt:1504搜「st.data_editor」) |
| st.popover 与设置编辑器 | 9 Creating an AI-powered application | text/18-ch09-9-creating-an-ai-powered-application.txt:1602搜「settings_editor」) |
| 设置注入提示词 | 9 Creating an AI-powered application | text/18-ch09-9-creating-an-ai-powered-application.txt:1668搜「Applying the settings」) |
Footnotes
-
出处:「9 Creating an AI-powered application」第 32–47 段(
text/18-ch09-9-creating-an-ai-powered-application.txt:32,搜「trivia game」)。需求七条见第 44–58 段。 ↩ -
出处:「9 Creating an AI-powered application」第 159–181 段(
text/18-ch09-9-creating-an-ai-powered-application.txt:178,搜「triple role」)。原文:「question generator, answer evaluator, and comic relief provider」。 ↩ -
出处:「9 Creating an AI-powered application」第 145–157 段(
text/18-ch09-9-creating-an-ai-powered-application.txt:150,搜「Large Language Model」)。 ↩ -
出处:「9 Creating an AI-powered application」第 183–187 段(
text/18-ch09-9-creating-an-ai-powered-application.txt:183,搜「not omniscient」)。 ↩ -
出处:「9 Creating an AI-powered application」第 596–645 段(
text/18-ch09-9-creating-an-ai-powered-application.txt:629,搜「shenanigans」)。 ↩ -
出处:「9 Creating an AI-powered application」第 223–285 段(
text/18-ch09-9-creating-an-ai-powered-application.txt:223,搜「Calling the OpenAI API」)。 ↩ -
出处:「9 Creating an AI-powered application」第 240–257 段(
text/18-ch09-9-creating-an-ai-powered-application.txt:247,搜「role and content」)。 ↩ -
出处:「9 Creating an AI-powered application」第 248–253 段(
text/18-ch09-9-creating-an-ai-powered-application.txt:250,搜「system represents instructions」)。 ↩ -
出处:「9 Creating an AI-powered application」第 260–269 段(
text/18-ch09-9-creating-an-ai-powered-application.txt:266,搜「comes next」)。 ↩ -
出处:「9 Creating an AI-powered application」第 271–275 段(
text/18-ch09-9-creating-an-ai-powered-application.txt:274,搜「pricing page」)。 ↩ -
出处:「9 Creating an AI-powered application」第 287–334 段(
text/18-ch09-9-creating-an-ai-powered-application.txt:287,搜「Llm class」)。listing 9.1 在 298–321 段。 ↩ -
出处:「9 Creating an AI-powered application」第 367–378 段(
text/18-ch09-9-creating-an-ai-powered-application.txt:372,搜「secrets.toml」)。 ↩ -
出处:「9 Creating an AI-powered application」第 412–438 段(
text/18-ch09-9-creating-an-ai-powered-application.txt:436,搜「machine-friendly」)。 ↩ -
出处:「9 Creating an AI-powered application」第 440–476 段(
text/18-ch09-9-creating-an-ai-powered-application.txt:452,搜「BaseModel」)。继承的简要解释见第 462–469 段。 ↩ -
出处:「9 Creating an AI-powered application」第 477–515 段(
text/18-ch09-9-creating-an-ai-powered-application.txt:488,搜「beta.chat.completions.parse」)。「传类本身」见第 512–515 段。 ↩ -
出处:「9 Creating an AI-powered application」第 596–623 段(
text/18-ch09-9-creating-an-ai-powered-application.txt:611,搜「evaluation.is_correct」)。 ↩ -
出处:「9 Creating an AI-powered application」第 491–558 段(
text/18-ch09-9-creating-an-ai-powered-application.txt:522,搜「prompts.py」)。 ↩ -
出处:「9 Creating an AI-powered application」第 627–645 段(
text/18-ch09-9-creating-an-ai-powered-application.txt:633,搜「bait-and-switch」)。 ↩ -
出处:「9 Creating an AI-powered application」第 647–845 段(
text/18-ch09-9-creating-an-ai-powered-application.txt:647,搜「Moving through game states」)。listing 9.7 在 713–753 段;前端分支在 773–804 段。 ↩ -
出处:「9 Creating an AI-powered application」第 777–781 段(
text/18-ch09-9-creating-an-ai-powered-application.txt:806,搜「same instance across reruns」)。 ↩ -
出处:「9 Creating an AI-powered application」第 821–831 段(
text/18-ch09-9-creating-an-ai-powered-application.txt:825,搜「status element」)。 ↩ -
出处:「9 Creating an AI-powered application」第 858–1054 段(
text/18-ch09-9-creating-an-ai-powered-application.txt:858,搜「score」)。 ↩ -
出处:「9 Creating an AI-powered application」第 1411–1439 段(
text/18-ch09-9-creating-an-ai-powered-application.txt:1434,搜「called a callback」)。 ↩ -
出处:「9 Creating an AI-powered application」第 1076–1083 段(
text/18-ch09-9-creating-an-ai-powered-application.txt:1080,搜「on its side」)。 ↩ -
出处:「9 Creating an AI-powered application」第 1101–1169 段(
text/18-ch09-9-creating-an-ai-powered-application.txt:1108,搜「probability distribution」)。but/then/a/this 的数值例子见第 1154–1166 段。 ↩ -
出处:「9 Creating an AI-powered application」第 1245–1249 段(
text/18-ch09-9-creating-an-ai-powered-application.txt:1245,搜「reasoning」)。 ↩ -
出处:「9 Creating an AI-powered application」第 1170–1175 段(
text/18-ch09-9-creating-an-ai-powered-application.txt:1174,搜「steer clear」)。 ↩ -
出处:「9 Creating an AI-powered application」第 1176–1193 段(
text/18-ch09-9-creating-an-ai-powered-application.txt:1178,搜「Mad Libs」)。 ↩ -
出处:「9 Creating an AI-powered application」第 1194–1198 段(
text/18-ch09-9-creating-an-ai-powered-application.txt:1195,搜「random seed」)。时间戳作种子的实现见第 1357–1362 段。 ↩ -
出处:「9 Creating an AI-powered application」第 1200–1205 段(
text/18-ch09-9-creating-an-ai-powered-application.txt:1201,搜「not pure science」)。 ↩ -
出处:「9 Creating an AI-powered application」第 1448–1485 段(
text/18-ch09-9-creating-an-ai-powered-application.txt:1448,搜「Cost considerations」)。 ↩ -
出处:「9 Creating an AI-powered application」第 196–207 段(
text/18-ch09-9-creating-an-ai-powered-application.txt:205,搜「15 cents」)。 ↩ -
出处:「9 Creating an AI-powered application」第 1498–1585 段(
text/18-ch09-9-creating-an-ai-powered-application.txt:1504,搜「st.data_editor」)。key 的怪癖见第 1541–1546 段。 ↩ -
出处:「9 Creating an AI-powered application」第 1586–1667 段(
text/18-ch09-9-creating-an-ai-powered-application.txt:1603,搜「st.popover」)。 ↩ -
出处:「9 Creating an AI-powered application」第 1668–1776 段(
text/18-ch09-9-creating-an-ai-powered-application.txt:1668,搜「Applying the settings」)。 ↩ -
出处:「9 Creating an AI-powered application」第 159–181 段(
text/18-ch09-9-creating-an-ai-powered-application.txt:178,搜「generator」)。 ↩ -
出处:「9 Creating an AI-powered application」第 488–494 段(
text/18-ch09-9-creating-an-ai-powered-application.txt:519,搜「field called prompt engineering」)。 ↩ -
出处:「9 Creating an AI-powered application」第 1194–1198 段(
text/18-ch09-9-creating-an-ai-powered-application.txt:1196,搜「not very clear」)。 ↩ -
出处:「9 Creating an AI-powered application」第 1087–1099 段(
text/18-ch09-9-creating-an-ai-powered-application.txt:1089,搜「hallucinations」)。 ↩