跳转至

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 的本质:它是按概率办事的——同样输入,输出会按一套概率 来回变动;普通代码则不然,同样输入必然同样输出。书里给了四层解法,一层比一层工程化:

  1. 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。)
  2. 把已问过的题写进提示词:{already_asked} 占位,换行拼接注入—— 同一局绝不重题,这是硬保证27。
  3. 让模型先想类目和主题——自己拓宽选择空间,比人维护类目表省事且更多样28。
  4. 随机种子: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. 可带走的

  1. 调 LLM = 组 messages(system 指令 + user 输入)→ chat.completions → 取 choices[0].message.content。
  2. 让程序读模型回答,用 Structured Outputs:pydantic BaseModel 定义模式,response_format 传类本身。
  3. 提示词全部外置一个文件;留占位符,代码侧 replace 填充;你会反复改它。
  4. 顺序型应用:状态属性 + 转换方法 + 前端 if/elif + 每步 st.rerun;有状态的自定义对象整体放 session_state。
  5. 长操作配 st.spinner;状态元素家族还有 st.status、st.toast、st.progress。
  6. temperature 拉平概率曲线(越发散),top_p 画累计概率截断线;出题 0.7/0.9 起步。
  7. 同局不重题 = 已问列表注进提示词;跨局多样 = 类目/主题让模型自选 + 时间戳种子。
  8. button 两种写法:if 式(易懂)vs on_click 回调(免手动 rerun、回调里不能 rerun)。
  9. LLM 按 token 双向计费;省钱的四杠杆:短提示、限输出、合并请求、换便宜模型。
  10. st.data_editor 把字典/表格变可编辑界面,column_config 定列类型;记得给 key。
  11. 设置进提示词:产品行为写在自然语言里,代码只负责填空。
  12. 幻觉客观存在;「实测频率不高」不是工程保证,接外部数据源才是(下一章)。

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」)


  1. 出处:「9 Creating an AI-powered application」第 32–47 段(text/18-ch09-9-creating-an-ai-powered-application.txt:32,搜「trivia game」)。需求七条见第 44–58 段。 ↩

  2. 出处:「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」。 ↩

  3. 出处:「9 Creating an AI-powered application」第 145–157 段(text/18-ch09-9-creating-an-ai-powered-application.txt:150,搜「Large Language Model」)。 ↩

  4. 出处:「9 Creating an AI-powered application」第 183–187 段(text/18-ch09-9-creating-an-ai-powered-application.txt:183,搜「not omniscient」)。 ↩

  5. 出处:「9 Creating an AI-powered application」第 596–645 段(text/18-ch09-9-creating-an-ai-powered-application.txt:629,搜「shenanigans」)。 ↩

  6. 出处:「9 Creating an AI-powered application」第 223–285 段(text/18-ch09-9-creating-an-ai-powered-application.txt:223,搜「Calling the OpenAI API」)。 ↩

  7. 出处:「9 Creating an AI-powered application」第 240–257 段(text/18-ch09-9-creating-an-ai-powered-application.txt:247,搜「role and content」)。 ↩

  8. 出处:「9 Creating an AI-powered application」第 248–253 段(text/18-ch09-9-creating-an-ai-powered-application.txt:250,搜「system represents instructions」)。 ↩

  9. 出处:「9 Creating an AI-powered application」第 260–269 段(text/18-ch09-9-creating-an-ai-powered-application.txt:266,搜「comes next」)。 ↩

  10. 出处:「9 Creating an AI-powered application」第 271–275 段(text/18-ch09-9-creating-an-ai-powered-application.txt:274,搜「pricing page」)。 ↩

  11. 出处:「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 段。 ↩

  12. 出处:「9 Creating an AI-powered application」第 367–378 段(text/18-ch09-9-creating-an-ai-powered-application.txt:372,搜「secrets.toml」)。 ↩

  13. 出处:「9 Creating an AI-powered application」第 412–438 段(text/18-ch09-9-creating-an-ai-powered-application.txt:436,搜「machine-friendly」)。 ↩

  14. 出处:「9 Creating an AI-powered application」第 440–476 段(text/18-ch09-9-creating-an-ai-powered-application.txt:452,搜「BaseModel」)。继承的简要解释见第 462–469 段。 ↩

  15. 出处:「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 段。 ↩

  16. 出处:「9 Creating an AI-powered application」第 596–623 段(text/18-ch09-9-creating-an-ai-powered-application.txt:611,搜「evaluation.is_correct」)。 ↩

  17. 出处:「9 Creating an AI-powered application」第 491–558 段(text/18-ch09-9-creating-an-ai-powered-application.txt:522,搜「prompts.py」)。 ↩

  18. 出处:「9 Creating an AI-powered application」第 627–645 段(text/18-ch09-9-creating-an-ai-powered-application.txt:633,搜「bait-and-switch」)。 ↩

  19. 出处:「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 段。 ↩

  20. 出处:「9 Creating an AI-powered application」第 777–781 段(text/18-ch09-9-creating-an-ai-powered-application.txt:806,搜「same instance across reruns」)。 ↩

  21. 出处:「9 Creating an AI-powered application」第 821–831 段(text/18-ch09-9-creating-an-ai-powered-application.txt:825,搜「status element」)。 ↩

  22. 出处:「9 Creating an AI-powered application」第 858–1054 段(text/18-ch09-9-creating-an-ai-powered-application.txt:858,搜「score」)。 ↩

  23. 出处:「9 Creating an AI-powered application」第 1411–1439 段(text/18-ch09-9-creating-an-ai-powered-application.txt:1434,搜「called a callback」)。 ↩

  24. 出处:「9 Creating an AI-powered application」第 1076–1083 段(text/18-ch09-9-creating-an-ai-powered-application.txt:1080,搜「on its side」)。 ↩

  25. 出处:「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 段。 ↩

  26. 出处:「9 Creating an AI-powered application」第 1245–1249 段(text/18-ch09-9-creating-an-ai-powered-application.txt:1245,搜「reasoning」)。 ↩

  27. 出处:「9 Creating an AI-powered application」第 1170–1175 段(text/18-ch09-9-creating-an-ai-powered-application.txt:1174,搜「steer clear」)。 ↩

  28. 出处:「9 Creating an AI-powered application」第 1176–1193 段(text/18-ch09-9-creating-an-ai-powered-application.txt:1178,搜「Mad Libs」)。 ↩

  29. 出处:「9 Creating an AI-powered application」第 1194–1198 段(text/18-ch09-9-creating-an-ai-powered-application.txt:1195,搜「random seed」)。时间戳作种子的实现见第 1357–1362 段。 ↩

  30. 出处:「9 Creating an AI-powered application」第 1200–1205 段(text/18-ch09-9-creating-an-ai-powered-application.txt:1201,搜「not pure science」)。 ↩

  31. 出处:「9 Creating an AI-powered application」第 1448–1485 段(text/18-ch09-9-creating-an-ai-powered-application.txt:1448,搜「Cost considerations」)。 ↩

  32. 出处:「9 Creating an AI-powered application」第 196–207 段(text/18-ch09-9-creating-an-ai-powered-application.txt:205,搜「15 cents」)。 ↩

  33. 出处:「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 段。 ↩

  34. 出处:「9 Creating an AI-powered application」第 1586–1667 段(text/18-ch09-9-creating-an-ai-powered-application.txt:1603,搜「st.popover」)。 ↩

  35. 出处:「9 Creating an AI-powered application」第 1668–1776 段(text/18-ch09-9-creating-an-ai-powered-application.txt:1668,搜「Applying the settings」)。 ↩

  36. 出处:「9 Creating an AI-powered application」第 159–181 段(text/18-ch09-9-creating-an-ai-powered-application.txt:178,搜「generator」)。 ↩

  37. 出处:「9 Creating an AI-powered application」第 488–494 段(text/18-ch09-9-creating-an-ai-powered-application.txt:519,搜「field called prompt engineering」)。 ↩

  38. 出处:「9 Creating an AI-powered application」第 1194–1198 段(text/18-ch09-9-creating-an-ai-powered-application.txt:1196,搜「not very clear」)。 ↩

  39. 出处:「9 Creating an AI-powered application」第 1087–1099 段(text/18-ch09-9-creating-an-ai-powered-application.txt:1089,搜「hallucinations」)。 ↩