聊天应用的多轮历史在哪一层拼:微软生成式 AI 入门课第 7 课
很多人是这样撞上这个问题的:照着课程把第一个聊天示例跑通,模型答得挺好,于是接着问第二句”那你刚才说的那个方案呢”,结果对面完全不知道你在说什么。第一反应通常是去翻第 7 课的 notebook,想找那段”把历史存起来”的代码。
翻完你会发现一件有点意外的事:07-building-chat-applications/python/ 下面的 Python notebook,一行历史维护的代码都没有。
这不是我在挑刺,是这一课的实际结构。搞清楚它为什么长这样、以及真正拼历史的代码在仓库的哪几个地方,比硬找一个不存在的函数有用得多。
第 7 课的 notebook 实际上在做什么
先把文件摊开。07-building-chat-applications/python/ 里是三条 provider 路线各两份,一共六个 .ipynb:oai-assigment-simple.ipynb 与 oai-assignment.ipynb(OpenAI)、aoai-assigment-simple.ipynb 与 aoai-assignment.ipynb(Azure OpenAI)、githubmodels-assignment-simple.ipynb 与 githubmodels-assignment.ipynb。
顺带提一句会让人找不到文件的小坑:oai 与 aoai 两条路线的 simple 版,文件名写的是 assigment(少一个 n),githubmodels 那两个才是 assignment。按 tab 补全没补出来别以为文件不存在。
OpenAI 路线的简版整个只有两个代码单元。第二个单元是这样的:
# Create your first prompt
text_prompt = " My foot hurts, what can be wrong?"
response = client.responses.create(
model=model,
input = [
{"role":"system", "content":"I'm a doctor, specialist on surgery"},
{"role":"user","content":text_prompt},],
store=False,)
response.output_text
这是 oai-assigment-simple.ipynb 里的原文。看清楚 input 这个列表:永远是两条,第一条 system,第二条 user,每次调用都重新写一遍字面量。完整版 oai-assignment.ipynb 里那几个练习——Summarize Text、Classify Text、Generate New Product Names——用的是同一个形状,只是把 system 的内容换成 "You are a helpful assistant.",把 prompt 变量换掉。从头到尾没有任何一个变量在跨调用地累积。
最能说明问题的是完整版里那对挨着的单元。markdown 标题写着 Repeat the same call, how do the results compare?,紧跟着的代码单元只留了 client.responses.create(...) 这一次调用本身,text_prompt 沿用上一个单元里已经赋好的值,两次调用送出去的 input 列表一模一样——上一轮的输出没有以任何形式回到第二次请求里。课程让你把同一次调用跑两遍去看输出差异——这个设计只有在”两次调用互不相关”的前提下才成立。也就是说,第 7 课的 Python 部分讲的是单轮请求的构造,不是会话状态的管理。
前置条件:先把路线和环境变量对上
在动手改之前,有三件事必须先确认,跳过任何一件后面都会卡住。
第一,选对 provider 路线,然后只读那一条的文件。 00-course-setup/03-providers.md 里写明了文件名标记的含义:oai 需要 OpenAI 的 endpoint 和 key,aoai 需要 Azure OpenAI 的 endpoint 和 key,githubmodels 需要 Microsoft Foundry Models 的 endpoint 和 key。同一课的三份文件用的 SDK 不一样,配置混着抄必然报错。
第二,装对包。 完整版 notebook 里带了装包单元:oai 与 aoai 两条路线是 %pip install openai python-dotenv(aoai-assignment.ipynb 的 Embeddings 一节后面还另有一句 %pip install matplotlib plotly scikit-learn pandas);githubmodels 路线是 %pip install azure-ai-inference,导入的是 from azure.ai.inference import ChatCompletionsClient。simple 版没有装包单元,环境得你自己先备好。
第三,.env 里的变量名照抄。 仓库根目录有 .env.copy,03-providers.md 让你先 cp .env.copy .env 再填值。OpenAI 路线读的是 OPENAI_API_KEY;Azure OpenAI 路线读 AZURE_OPENAI_API_KEY、AZURE_OPENAI_ENDPOINT、AZURE_OPENAI_DEPLOYMENT;Foundry Models 路线读 AZURE_INFERENCE_CREDENTIAL 和 AZURE_INFERENCE_ENDPOINT。key 一律写成占位符 <YOUR_API_KEY> 提交,这个文件在 .gitignore 里,别自己把它加回去。
Windows 侧补一句:06-text-generation-apps/README.md 的建虚拟环境那一步给的是 source venv/bin/activate,紧跟着一个 note 写明 Windows 要改用 venv\Scripts\activate。在 VS Code 里跑 notebook 的话,还要确认右上角选的 kernel 就是你刚激活的那个环境,否则 %pip install 装到了另一个解释器里。
历史列表到底怎么拼
课程里真正把多轮历史写出来的地方,在第 4 课。04-prompt-engineering-fundamentals/python/oai-assignment.ipynb 的 Exercise 5,markdown 里写着 System sets assistant context、User & Assistant messages provide multi-turn conversation context,下面的代码是这样:
response = client.responses.create(
model=deployment,
input=[
{"role": "system", "content": "You are a sarcastic assistant."},
{"role": "user", "content": "Who won the world series in 2020?"},
{"role": "assistant", "content": "Who do you think won? The Los Angeles Dodgers of course."},
{"role": "user", "content": "Where was it played?"}
],
store=False,
)
这四条消息把规则说清楚了:
system在列表第一个位置,整段对话里只有这一条,后面追加的都排在它之后。三条 provider 路线的示例无一例外都是这个顺序。assistant那条是手写进去的,内容就是上一轮模型的回复。多轮之所以成立,是因为你把模型说过的话又原样送了回去。- 最后一条是本轮的
user。第四条问Where was it played?,能答上来完全依赖前面那条assistant消息还在列表里。
至于怎么让这个列表自己长起来,仓库里有现成写法。.github/skills/azure-openai-to-responses/references/cheat-sheet.md 的 Multi-turn conversation 一节:
messages = [
{"role": "system", "content": "You are a helpful coding assistant."},
{"role": "user", "content": "Write a Python function to calculate factorial"},
]
response = client.responses.create(model=deployment, input=messages, max_output_tokens=400)
messages.append({"role": "assistant", "content": response.output_text})
messages.append({"role": "user", "content": "Now optimize it with memoization"})
response2 = client.responses.create(model=deployment, input=messages, max_output_tokens=400)
模式就是:同一个 messages 列表反复 append,每轮先补上模型的回复,再补上新的用户输入,然后整个列表重新交给 input。 历史不在服务端,就在你这个进程的一个 Python list 里。max_output_tokens=400 是这份文件里的示例值,换成你自己的场景要自己定。
第 11 课的函数调用示例走的是同一条路子:11-integrating-with-function-calling/python/oai-assignment.ipynb 里先 messages.extend(response.output) 把模型发出的 function call 条目原样接回列表,再 messages.append({"type": "function_call_output", "call_id": ..., "output": ...}) 补上函数执行结果,然后把整个 messages 交给第二次 client.responses.create(...)。注释里写明 Responses API 用 function_call_output 条目来表示工具结果。整个课程的会话状态,都是这种”客户端自己拿着列表”的模型。
边界:几处必须知道的落差
store=False 挡住了另一条路。 同一个 skill 的 SKILL.md 里写着一行迁移要点:多轮可以在 input 数组里自己维护,也可以用 previous_response_id,而后者 requires store=True。再看第 7 课走 Responses API 的那两条路线(oai 与 aoai)——每一次 client.responses.create(...) 都逐个写死了 store=False;同一份 SKILL.md 的迁移清单里也明确要求”每个请求都设 store=False”。(githubmodels 路线用的是 client.complete(...),不是 Responses API,压根没有这个参数,也就谈不上 previous_response_id。)两处放在一起,结论很清楚:照课程的写法,你只有”自己拼数组”这一条路可走,previous_response_id 那条需要你主动改掉 store 才谈得上。课程没有展开讲这个取舍。
githubmodels 路线有一处导入与实际用法对不上。 githubmodels-assignment-simple.ipynb 第一个单元写着 from azure.ai.inference.models import SystemMessage, UserMessage,但下面真正调用时传的是普通字典:messages = [{"role":"system", ...}, {"role":"user", ...}]。两个导入进来的类没有被用到。完整版 githubmodels-assignment.ipynb 也是同样情况。你按哪种写都行,但心里要清楚这两种形态在同一个文件里同时出现过。另外这条路线的注释写明 GitHub Models 将退役、示例已改指 Microsoft Foundry Models,选路线前先看清楚文件顶部那段注释。
第 7 课的讲解文字和代码有一处对不上。 完整版 Generate New Product Names 那一节的 markdown 写着”我们把 temperature 设得比较高以增加随机性”,但紧跟着的代码单元里根本没有 temperature 参数。再翻 .env.copy 的注释,那里写明 gpt-5-mini 是 reasoning 模型、不支持 temperature/top_p,要试 temperature 得换非 reasoning 模型。讲解文字是早期版本留下来的,代码已经改过了——照代码走,别照文字走。
第 7 课的 README 只在 UX 层面提了一句上下文。 它在 User Experience 一节里把 Context retention 列为设计考量,提醒你保留上下文会带来敏感信息留存的问题、可以考虑保留期限策略。这是产品层面的提醒,不是实现指引,README 里没有对应的代码。
简版和完整版差在哪
两者的差别不在功能,在教学密度,但有几行是实打实不一样的:
| 对比项 | simple 版 | 完整版 |
|---|---|---|
| 单元数量 | 建 client + 一次调用,共两个代码单元 | 安装、导入、选模型、若干 markdown 讲解,加三个练习 |
| system 内容 | "I'm a doctor, specialist on surgery" | "You are a helpful assistant." |
| 装包单元 | 没有 | 有 %pip install |
| 读 key 的写法 | os.getenv("OPENAI_API_KEY","").strip() 后 assert | os.getenv("OPENAI_API_KEY","") 后 assert,没有 .strip() |
| 额外内容 | 无 | aoai-assignment.ipynb 多一个 Embeddings 节,用 AZURE_OPENAI_EMBEDDINGS_DEPLOYMENT 和自定义的 cosine_similarity |
aoai-assigment-simple.ipynb 还多一道校验:它 assert 你的 AZURE_OPENAI_ENDPOINT 里含有 openai.azure.com,填错域名会当场拦下来,完整版没有这道检查。
有一点两版完全一致:都不维护历史。 想练多轮,得自己动手把上面那个 append 循环搬进来。
怎么验证你真的拼对了
改完之后别急着看模型答得对不对,先看你送出去的东西对不对。发请求前把列表本身打出来:
print(len(messages))
print(messages[0]["role"], "|", messages[0]["content"][:40])
for m in messages[-2:]:
print(m["role"], "|", m["content"][:40])
三个判据:列表长度每轮增加 2;messages[0]["role"] 始终是 system 且全程只出现一次;倒数第二条是 assistant、最后一条是 user。哪一条不满足,问题就在你的拼装逻辑里,跟模型没关系。
以上为按仓库代码中的接口语义组合的示例,未经实测,以仓库最新代码为准。
再补一个行为上的判据:仿照第 4 课 Exercise 5 的问法,第二轮故意问一个只有靠上一轮才能答的指代问题(那个示例问的是 Where was it played?)。如果历史没送进去,模型会反问你在说什么。第 7 课完整版那对”重复同一次调用”的单元,反过来正好是没有历史时的对照组。
最后提醒一句:这一课的示例每一次调用都会走云端 API,.env 里的 key 和你送进 messages 的全部历史都会发出去。历史列表越长,送出去的内容越多——把用户的完整对话原样堆在列表里之前,先想清楚哪些内容不该离开你的机器。
本文依据 github.com/microsoft/generative-ai-for-beginners 仓库于 2026-08-18 的公开内容整理,
事实来自仓库内的课程正文与代码示例。我们没有跑过文中涉及的代码,
因此不涉及运行结果、耗时与生成质量的任何描述。
该课程持续更新,文中涉及的文件路径、依赖与接口写法随版本变动,请以仓库最新内容为准。
文中涉及的云端服务调用会产生费用并可能上传数据,请自行评估密钥与数据边界。