阶段五 AI 应用实验学习总结:从 API 调用到 RAG、Agent 与多模态的完整实践
本文是我在阶段五 AI 应用实验中的完整学习总结,涵盖实验三十四到三十八共五个实验。从最基础的大模型 API 调用,一步步走到 RAG 检索增强生成、Streamlit 网页应用、ReAct Agent、多模态图像识别,最后以一个综合实战工具收尾。全文按"原理深挖 → 代码逐行拆解 → 运行流程演示 → 踩坑与思考"的顺序展开,既是学习笔记,也是可以照着复现的教程。
目录
- 一、前言:为什么写这篇总结
- 二、阶段五整体认识
- [三、实验三十四:构建 RAG(检索增强生成)](#三、实验三十四:构建 RAG(检索增强生成))
- [四、实验三十五:Streamlit 快速搭建 UI](#四、实验三十五:Streamlit 快速搭建 UI)
- [五、实验三十六:简单 Agent 思路(ReAct)](#五、实验三十六:简单 Agent 思路(ReAct))
- 六、实验三十七:多模态入门(图像识别)
- [七、实验三十八:综合实战------AI 翻译+润色工具](#七、实验三十八:综合实战——AI 翻译+润色工具)
- 八、横向对比与知识串联
- 九、学习收获与心得
- 十、踩坑总结
- 十一、后续学习方向
一、前言:为什么写这篇总结
在正式进入实验之前,我的 Python 基础有一些,HTTP API 调用也大致了解,但对"大模型应用到底怎么从零搭起来"始终没有一个完整的、能落地的认知。网上资料要么太浅(只讲怎么调一次 API),要么太深(一上来就 LangChain 全家桶),很难找到一个适合"有一点基础的学生"逐步吃透的路径。
阶段五这套实验设计得很好,它从最简单的"调一次 API 生成对联"起步,每个实验只引入一个新概念,后一个实验复用前一个的代码,形成一条平滑的进阶曲线。到实验三十四至三十八这五个,已经能组合出 RAG、Agent、多模态这些当下最热门的 AI 应用范式。
写这篇总结有三个目的:
- 巩固知识:教是最好的学,把每个实验的原理和代码讲清楚,自己就真懂了;
- 留一份可复现的记录:以后自己复习或同学问起,直接发链接;
- 沉淀一套"看 AI 应用代码"的方法论 :这五个实验底层都是
requests.post调 API,看透这层,以后遇到任何新的 AI 应用代码都不怵。
全文约三万字,建议按顺序读,因为后面的实验会复用前面的概念。如果你只想看某一个实验,可以直接跳到对应章节,每章都是自洽的。
二、阶段五整体认识
2.1 阶段五的目标与产出
阶段五的官方定位是"AI 应用实验",先修知识是 Python 基础和 HTTP API 调用,共 10 课时。核心目标用一句话概括:学会调用大模型 API,掌握提示词工程,构建 AI 增强型应用。最终产出要求是一个 Streamlit 网页应用,比如"AI 翻译润色工具"或"知识库问答助手"。
技术栈很轻:Python + 智谱 AI(或豆包/OpenAI)的 API + Streamlit,LangChain 可选。我选的是智谱 AI,因为它有免费额度,对学生友好,而且 API 格式兼容 OpenAI,学一套通用。
2.2 十个实验的进阶路线
整个阶段五从实验二十九到三十八,共十个实验,我画一张进阶图:
| 实验 | 主题 | 引入的新概念 |
|---|---|---|
| 29 | 对联生成器 | 大模型 API 调用、提示词、temperature |
| 30 | 带记忆聊天机器人 | 多轮对话、上下文管理(消息列表) |
| 31 | 模拟面试官 | 提示词工程(角色设定、思维链) |
| 32 | 天气查询助手 | 函数调用(Function Calling) |
| 33 | Embedding 与向量检索 | 文本转向量、余弦相似度检索 |
| 34 | RAG 检索增强生成 | 检索 + 生成组合 |
| 35 | Streamlit 网页聊天 | Web 框架、session_state |
| 36 | ReAct Agent | 多步工具调用循环 |
| 37 | 多模态图像识别 | base64、视觉模型 |
| 38 | AI 翻译润色工具 | Streamlit + 提示词工程综合 |
可以看到一条清晰的脉络:29-32 教"怎么和 LLM 对话"(单轮→多轮→提示词→函数调用),33 教"怎么让 LLM 用你的数据"(embedding 检索),34 把 33 和 LLM 组合成 RAG,35 把命令行变网页,36 把 32 的函数调用升级成多步 Agent,37 引入视觉,38 综合收尾。
本文聚焦后五个(34-38),因为它们是阶段五的核心产出,也是最能体现"AI 应用工程化"的部分。前五个(29-33)是基础,我在文中会顺带提一句它们的作用,不展开。
2.3 一个贯穿始终的认知
在拆具体代码之前,先建立一个贯穿五个实验的认知,它能让后面所有代码都变得好懂:
这五个实验,底层全部是
requests.post()调智谱 AI 的 HTTP API,区别只在"请求体 payload 怎么构造"和"返回的 JSON 怎么解析"。
- 纯文本对话:payload 里
messages是文本,模型用glm-4-flash; - RAG:先调 embedding API 拿向量,再调 chat API 生成;
- Agent:payload 多一个
tools字段,解析返回里的tool_calls; - 多模态:
messages的content从字符串变成"图片+文本"数组,模型换成glm-4v-flash; - Streamlit:业务逻辑和命令行版一模一样,只是把
input()/print()换成 Streamlit 组件。
看透这层,五个实验就是同一个模式的五种变体。后面每个实验我都会指出"这次 payload 多了什么、返回多了什么",帮你强化这个认知。
三、实验三十四:构建 RAG(检索增强生成)
对应文件:
rag.py。这是我认为阶段五里信息密度最高的一个实验,它把实验三十三的 Embedding 检索和大模型生成拼在一起,解决了一个非常实际的问题:让 LLM 基于你自己的文档回答问题。
3.1 RAG 要解决什么问题
直接问大模型"什么是强化学习",它凭训练时背下的知识回答,看起来没问题。但有两个场景它会出问题:
场景一:私有知识。 我有一份 knowledge.txt,里面是我整理的 AI 知识百科。我问大模型"这份文档里提到了哪些 CNN 模型",它根本没见过这份文档,答不上来。
场景二:幻觉(Hallucination)。 问它一个稍微冷门的问题,它没把握时会一本正经地编造,看起来很像那么回事,其实是错的。这在严肃场景(医疗、法律、企业内部知识库)里是致命的。
RAG(Retrieval-Augmented Generation,检索增强生成)的思路很朴素:不改模型本身,而是在每次提问前,先从你的文档里"翻"出最相关的几段,把这几段当作上下文连同问题一起喂给 LLM,让它照着上下文答,而不是凭记忆瞎编。
我最喜欢的一个类比是开卷考试:LLM 是考生,你的文档是课本,"检索"就是翻到相关页码,然后把那一页内容和题目一起递给考生,让他照着答。这样既能用到你的私有知识,又能显著减少幻觉。
3.2 为什么不直接把整个文档塞给 LLM
这是我在学 RAG 时第一个冒出来的疑问。既然要让 LLM 基于文档回答,为什么不把整篇文档直接塞进 prompt?三个原因:
第一,Token 限制。 LLM 单次输入有上限,glm-4-flash 是 128K token,文档一大就逼近上限,而且按 token 计费,整篇塞进去又慢又贵。
第二,"迷失在中间"现象(Lost in the Middle)。 有研究表明,LLM 对超长上下文中中间的内容注意力会下降,开头和结尾记得清楚,中间容易忘。所以精准检索出 3 小块,效果反而比塞一整篇好。
第三,成本和速度。 只送 3 块(几百字)比送整篇快得多、便宜得多,对交互式问答尤其重要。
这就是为什么要"先检索再生成",而不是"全塞进去让它自己找"。
3.3 RAG 的五步流程
RAG 的完整流程可以拆成五步,对应 rag.py 里的五个函数:
| 步骤 | 做什么 | 对应函数 |
|---|---|---|
| ① 加载文档 | 读 docs/knowledge.txt 全文 |
build_index() |
| ② 分块 chunk | 把长文档切成约 200 字一块 | chunk_text() |
| ③ 向量化 embedding | 每块调 embedding API 转成 1024 维向量 | get_embedding() |
| ④ 检索 | 问题也转向量,和每块算余弦相似度,取最像的 top3 | search() |
| ⑤ 生成 | top3 块拼成上下文 + 问题 → 喂 LLM → 出答案 | generate_answer() |
其中 ①②③ 是"建索引",只做一次(代码还会把结果缓存到 rag_cache.pkl,下次直接加载);④⑤ 是"问答",每提一个问题做一次。main() 函数就是把这五步串起来:先建索引,然后进 while 循环,每次提问 → 检索 → 生成。
下面逐个函数拆。
3.4 chunk_text():文本分块
python
def chunk_text(text, chunk_size=200, overlap=20):
"""将文本按固定长度分块"""
chunks = []
start = 0
while start < len(text):
end = start + chunk_size
chunk = text[start:end].strip()
if chunk:
chunks.append(chunk)
start = end - overlap
return chunks
这是一个纯 Python 函数,不依赖任何 API,是后面所有步骤的地基。逐行看:
chunks = []:存放切好的块;start = 0:起始指针,从第 0 个字开始;while start < len(text):只要没切到文档末尾就继续;end = start + chunk_size:结束指针 = 起点 + 200;chunk = text[start:end].strip():切出这一段,去掉首尾空白;if chunk: chunks.append(chunk):切完如果是空串就不存;start = end - overlap:下一段起点 = 本段终点 - 20,往回退 20 字形成重叠。
关键就是最后一行 start = end - overlap。如果没有 overlap(overlap=0),下一段从 end 开始,前后块首尾相接、不重叠。现在 overlap=20,下一段从 end - 20 开始,和本段末尾共享 20 字。
为什么要重叠? 如果刚好在一个完整句子中间切断,那句子的语义就残缺了。重叠 20 字让相邻块共享一小段,边界信息不丢失。这是工程上的小技巧。
我用项目里的 knowledge.txt(一篇关于 AI 的百科,共 1446 字)真实跑了一遍:
文档总字数: 1446
切分后块数: 9
--- 第1块 (长度200) ---
人工智能(Artificial Intelligence,简称 AI)是计算机科学的一个重要分支,
致力于研究、开发用于模拟、延伸和扩展人类智能的理论、方法、技术及应用系统。
机器学习是人工智能的核心子领域,它使计算机系统能够从数据中自动...
--- 第2块 (长度199) ---
监督学习使用带标签的训练数据来学习输入与输出之间的映射关系。
典型应用包括分类问题(如垃圾邮件识别、图像分类)和回归问题(如房价预测)...
1446 字切成 9 块,每块约 200 字。每一步窗口往前推进 chunk_size - overlap = 200 - 20 = 180 字。
3.5 Embedding 向量:把文本变成一串数字
在讲 get_embedding() 之前,必须先理解 embedding 是什么,因为它是整个检索的数学基础。
Embedding 就是把一段文本翻译成一串数字 (智谱 embedding-2 模型输出 1024 个浮点数)。它有一个关键性质:
语义相近的文本,向量也相近(在 1024 维空间里距离小)。
比如"强化学习是......"和"AlphaGo 是强化学习的经典应用"这两段,虽然字面不同,但向量挨得很近。用户问"AlphaGo 用了什么技术",问题转向量后,和讲强化学习那块的向量距离最近,就被检索出来。
本质是把"语义匹配"变成了"向量距离比较"------计算机擅长算距离,不擅长直接比语义。embedding 就是这座桥。
3.6 余弦相似度:怎么算"相近"
有了向量,还要定义"相近"的度量。代码用的是余弦相似度(cosine similarity):
python
def cosine_similarity(vec1, vec2):
v1, v2 = np.array(vec1), np.array(vec2)
return np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2))
公式是 cosθ = (a·b) / (|a| × |b|),即点积除以两个模长的乘积。结果范围 -1, 1,越接近 1 表示方向越一致(越相似)。
为什么用余弦而不是欧氏距离?因为余弦只看方向不看长度,文本长短不影响相似度判断。一段 10 字的和一段 100 字的,只要语义方向一致,余弦相似度照样高。
用二维向量直观感受:
(1,0) vs (1,0) 完全相同: 1.0
(1,0) vs (1,1) 同向偏一点: 0.707
(1,0) vs (0,1) 垂直无关: 0.0
(1,0) vs (-1,0) 完全相反: -1.0
方向越一致,值越接近 1。文本向量虽然 1024 维画不出来,但道理一样。
3.7 get_embedding():调 embedding API
python
def get_embedding(text):
payload = {"model": "embedding-2", "input": text}
for attempt in range(3):
try:
response = requests.post(EMBED_URL, headers=headers, json=payload, timeout=30)
if response.status_code == 429:
wait = (attempt + 1) * 5
print(f" [限流,等待{wait}秒后重试...]")
time.sleep(wait)
continue
response.raise_for_status()
return response.json()['data'][0]['embedding']
except requests.exceptions.Timeout:
if attempt < 2:
time.sleep(3)
continue
raise
raise Exception("Embedding API 调用失败(重试3次仍被限流)")
核心就两处:构造 payload = {"model": "embedding-2", "input": text} 发 POST 请求,然后从返回 JSON 里取 data[0]['embedding'],那就是 1024 个浮点数。
其余都是容错:429 是请求太频繁被限流,代码会等待后重试,最多 3 次;超时也重试。这是生产代码的写法,初学先记住"它返回一个 1024 维向量"即可。
3.8 build_index():建索引(带缓存)
python
def build_index():
if os.path.exists(CACHE_PATH):
with open(CACHE_PATH, 'rb') as f:
return pickle.load(f)
with open(DOC_PATH, 'r', encoding='utf-8') as f:
text = f.read()
chunks = chunk_text(text, 200, 20)
vectors = []
for i, chunk in enumerate(chunks):
vectors.append(get_embedding(chunk))
time.sleep(1)
with open(CACHE_PATH, 'wb') as f:
pickle.dump((chunks, vectors), f)
return chunks, vectors
主线:读文档 → 分块 → 每块调 embedding 转向量 → 返回 (chunks, vectors) 两个对齐的列表(第 i 块对应第 i 个向量)。
两个缓存点:开头如果 rag_cache.pkl 存在就直接 pickle.load 加载,跳过整个建索引过程;结尾把建好的 (chunks, vectors) 用 pickle.dump 存起来。pickle 是 Python 自带的对象序列化库,能把任意对象存成二进制文件。
为什么要缓存?因为 embedding 要调 API,花钱花时间。9 块文档要调 9 次 API,每次还要 time.sleep(1) 防限流,第一次建索引要十几秒。缓存后下次启动瞬间加载,不用重新调。
3.9 search():检索最相关的 top3 块
python
def search(query, chunks, vectors, top_k=3):
query_vec = get_embedding(query)
similarities = [cosine_similarity(query_vec, vec) for vec in vectors]
top_indices = np.argsort(similarities)[-top_k:][::-1]
return [chunks[i] for i in top_indices], [similarities[i] for i in top_indices]
三步:问题转向量 → 和每块算余弦相似度 → 取最大的 3 个的下标。
关键是 np.argsort(similarities)[-top_k:][::-1] 这个切片技巧,拆开看:
所有相似度: [0.12, 0.98, 0.45, 0.77, 0.03, 0.60]
argsort升序下标: [4, 0, 2, 5, 3, 1] # 相似度从小到大排,下标1的0.98最大排最后
[-3:] 取最后3个: [5, 3, 1] # 最大的3个
[::-1] 反转成从大到小: [1, 3, 5] # 0.98, 0.77, 0.60
np.argsort 返回按值升序排列的下标数组,[-top_k:] 取最后 3 个(即最大的 3 个),[::-1] 反转让它从大到小。一行代码完成"取 top3 并按相似度降序"。
3.10 generate_answer():结合上下文生成答案
python
def generate_answer(question, context_chunks):
context = "\n\n".join(context_chunks)
prompt = (
f"请基于以下上下文回答问题。如果上下文中没有相关信息,请说\"未找到相关信息\"。\n"
f"回答时要准确引用上下文内容,不要编造信息。\n\n"
f"上下文:\n{context}\n\n"
f"问题:{question}\n\n"
f"答案:"
)
payload = {
"model": "glm-4-flash",
"messages": [{"role": "user", "content": prompt}],
"temperature": 0.3,
"max_tokens": 300
}
response = requests.post(CHAT_URL, headers=headers, json=payload, timeout=30)
response.raise_for_status()
return response.json()['choices'][0]['message']['content']
把 top3 块用空行拼成一坨上下文,构造提示词,调 chat API 生成答案。
提示词是 RAG 的灵魂:明确要求"基于上下文回答,如果上下文中没有相关信息就说'未找到相关信息',不要编造信息"------这就是抑制幻觉的关键指令。如果没有这段约束,LLM 可能在上下文不够时自行脑补,又回到幻觉的老路。
temperature=0.3 也值得注意。temperature 控制随机性:低(0.3)= 保守确定,适合事实问答;高(0.8)= 发散有创意,适合写诗对联。对比实验二十九的对联生成器用的 0.8,RAG 要准确,所以用低温度。这个参数的选择要匹配任务性质。
3.11 完整数据流
把五个函数串起来,rag.py 的完整数据流是这样的:
knowledge.txt ──读──→ 全文 ──chunk_text──→ 9个块
└──get_embedding──→ 9个向量 ┐─缓存pkl
│
用户问题 ──get_embedding──→ 问题向量 ──┤
└── 对每个块算cosine相似度 ──→ 取top3块
│
top3块 + 问题 ──generate_answer──→ LLM ──→ 答案
建索引(读→分块→向量化→缓存)只做一次,问答(问题向量化→检索→生成)每次提问都做。
3.12 RAG vs 微调
学完 RAG,自然会问:它和微调(Fine-tuning)有什么区别?都是让模型用你的数据。
| RAG | 微调 | |
|---|---|---|
| 比喻 | 开卷考试:考前翻书 | 背书:把知识背进脑子 |
| 改没改模型 | 没改,推理时挂外部知识 | 改了模型参数 |
| 知识更新 | 换文档即可,立刻生效 | 要重新训练,慢且贵 |
| 适合场景 | 知识频繁变、有私有文档 | 要改变模型风格或能力 |
RAG 的优势是便宜、知识可随时更新、不需要 GPU 训练;微调的优势是能改变模型本身的"性格"和"能力"(比如让它学会某种特定文风)。实际项目里两者经常结合:用微调让模型适配领域语言风格,用 RAG 注入实时知识。
3.13 实验三十四的思考题
1. RAG 相比单纯微调有什么优势? 上面表格已答:便宜、知识实时更新、无需训练。补充一点:RAG 还能引用来源(把检索到的块展示给用户),可解释性更好,这在企业知识库场景很重要。
2. 如何优化检索,比如使用 HyDE? HyDE(Hypothetical Document Embeddings)的思路是:先让 LLM 根据问题"假想"一个答案文档,用这个假想文档的向量去检索,而不是直接用问题向量。因为"假想答案"和真实答案文档在语义上更接近,检索效果往往更好。代价是多一次 LLM 调用。
3. 如何解决检索结果过多导致超出 token 限制? 几种办法:减小 top_k;对检索到的块做二次压缩/摘要;按相似度阈值过滤掉低分块;用支持更长上下文的模型。
四、实验三十五:Streamlit 快速搭建 UI
对应文件:
chat_app.py。这个实验本身不难,但 Streamlit 有一个反直觉的运行机制,不懂这个机制,代码会看懵。本节先讲机制,再拆代码。
4.1 Streamlit 是什么
Streamlit 是一个 Python Web 框架,最大的特点是纯 Python 写交互式网页,不用写 HTML/CSS/JavaScript。特别适合快速做 AI/ML 的 demo 或内部工具------你只关心 Python 逻辑,界面它自动生成。
运行方式也和普通脚本不同:不是 python xxx.py,而是 streamlit run chat_app.py,它会自动起一个本地服务并打开浏览器(默认 http://localhost:8501)。
4.2 🔑 Streamlit 的"反直觉"运行机制
这是理解所有 Streamlit 代码的钥匙,必须先懂。
普通 Python 脚本:从头跑到尾一次,结束。
Streamlit 脚本:每次用户交互(点按钮、输入回车)都会从头到尾重新运行整个脚本一次。
举个例子:用户在输入框打字回车 → 整个 chat_app.py 从第 1 行重新跑到最后一行 → 渲染出新页面。
这带来一个致命问题:普通变量每次重跑都会被重新初始化 。比如你在脚本里写 messages = [],用户说一句话触发重跑,messages 又变回空 []------对话历史丢了!
解决方案是 st.session_state :一个特殊字典,它的内容跨重跑保留。把历史存进 st.session_state.messages,就不会丢。这就是为什么 Streamlit 代码里到处是 if 'xxx' not in st.session_state: 这种写法------只在第一次跑时初始化,之后重跑就跳过,保留旧值。
记住这句话:Streamlit 脚本每次交互全量重跑,状态靠 session_state 跨重跑保留。 懂了这句,所有 Streamlit 代码都能看懂。
4.3 常用组件速查
代码里会用到这些组件,先混个脸熟:
| 组件 | 作用 |
|---|---|
st.set_page_config |
页面配置(标题、图标、布局),必须是第一个 st 命令 |
st.title / st.caption |
大标题 / 副标题 |
st.chat_message(role) |
聊天气泡容器,role="user" 在右、"assistant" 在左 |
st.chat_input() |
底部输入框,回车提交 |
st.write() |
显示文本(万能显示) |
st.button() / st.spinner() |
按钮 / 加载提示 |
st.rerun() |
强制重新跑一次脚本 |
st.sidebar |
侧边栏 |
st.session_state |
跨重跑保留的状态字典 |
4.4 代码拆解:配置与状态初始化
python
# 页面配置
st.set_page_config(
page_title="AI 聊天机器人",
page_icon="🤖",
layout="centered"
)
st.title("🤖 AI 聊天机器人")
st.caption("基于智谱 AI GLM-4-Flash 模型 | 实验三十五")
# 初始化会话状态
if 'messages' not in st.session_state:
st.session_state.messages = [
{"role": "system", "content": "你是一个友好的聊天机器人,请用中文回答。"}
]
set_page_config 设置浏览器标签页的标题、图标和页面布局(centered 居中、wide 宽屏),它必须是脚本里第一个 Streamlit 命令,否则无效。
st.title / st.caption 是页面上显示的大标题和副标题。
关键是状态初始化那段:if 'messages' not in st.session_state: 只在第一次跑时进入(此时 'messages' 还不存在),把它初始化成只有一条 system 消息。system 消息定义机器人的角色------"你是一个友好的聊天机器人,请用中文回答"。之后每次重跑,'messages' 已存在,if 为假,跳过初始化,保留之前累积的对话历史。这就是"记忆"的实现。
4.5 代码拆解:侧边栏
python
with st.sidebar:
st.header("控制面板")
if st.button("🗑️ 清空对话", use_container_width=True):
st.session_state.messages = [
{"role": "system", "content": "你是一个友好的聊天机器人,请用中文回答。"}
]
st.rerun()
st.divider()
st.markdown("### 对话统计")
user_msgs = [m for m in st.session_state.messages if m['role'] == 'user']
st.metric("对话轮数", len(user_msgs))
st.metric("总消息数", len(st.session_state.messages))
with st.sidebar: 是一个上下文管理器,里面写的所有组件都显示在左侧边栏。
清空对话按钮:点击后把 messages 重置为只剩 system 消息,然后 st.rerun() 强制重新跑一次脚本刷新页面。use_container_width=True 让按钮占满侧边栏宽度,好看一点。
下面是统计:st.metric 显示数字指标。user_msgs 用列表推导式筛出所有 role 为 user 的消息,其长度就是对话轮数。
4.6 代码拆解:显示聊天记录
python
for msg in st.session_state.messages:
if msg['role'] != 'system':
with st.chat_message(msg['role']):
st.write(msg['content'])
遍历 session_state 里所有消息,跳过 system 消息(它是给 API 的角色设定,不该显示给用户),其余每条用 st.chat_message(role) 创建对应角色的气泡,st.write 在气泡里写内容。
注意:每次重跑都遍历全部历史重新渲染。 这是 Streamlit 的常态------它不"增量更新 DOM",而是"全量重画"。听起来低效,但对于 demo 级应用完全够用,换来的是极简的编程模型(你只写"页面长什么样",不用管 diff)。
4.7 代码拆解:处理用户输入
python
if prompt := st.chat_input("请输入消息..."):
with st.chat_message("user"):
st.write(prompt)
st.session_state.messages.append({"role": "user", "content": prompt})
with st.chat_message("assistant"):
with st.spinner("思考中..."):
try:
response = call_llm(st.session_state.messages)
st.write(response)
except Exception as e:
response = f"出错: {e}"
st.error(response)
st.session_state.messages.append({"role": "assistant", "content": response})
这里出现了一个可能不熟的语法:海象运算符 := (Python 3.8+ 引入)。if prompt := st.chat_input("请输入消息..."): 等价于:
python
prompt = st.chat_input("请输入消息...") # 先赋值
if prompt: # 再判断是否非空
只是合并成一行。st.chat_input 没输入时返回 None(if 为假,什么都不做),用户输入回车时返回字符串(if 为真,进入处理)。
处理流程:
- 先用
st.chat_message("user")画一个用户气泡,st.write(prompt)显示用户说的话; - 把用户消息加入
session_state.messages历史; - 画一个 assistant 气泡,里面用
st.spinner("思考中...")显示加载提示,调用call_llm(st.session_state.messages)拿回复; - 把 AI 回复也加入历史。
注意 call_llm 传的是整个 session_state.messages(含全部历史),这就是多轮对话有记忆的来源------不是模型自己记的,而是你每次把完整历史都传过去。
4.8 完整运行循环
把机制串起来,一次完整的交互是这样的:
用户在输入框打字回车
→ 整个 chat_app.py 从第1行重跑
→ 遍历 session_state 里所有历史,重新渲染成气泡
→ st.chat_input 拿到新输入 prompt
→ 画 user 气泡 + 加入历史
→ call_llm(带全部历史) 拿回复
→ 画 assistant 气泡 + 加入历史
→ 脚本结束,页面显示完整对话
用户再回车 → 又从头重跑 → 连刚加的也一起渲染 → ...
4.9 call_llm() 和命令行版的关系
python
def call_llm(messages):
payload = {
"model": "glm-4-flash",
"messages": messages,
"temperature": 0.8
}
response = requests.post(CHAT_URL, headers=headers, json=payload, timeout=30)
response.raise_for_status()
return response.json()['choices'][0]['message']['content']
这个函数和实验三十的命令行聊天机器人 chatbot.py 里的完全一样:把 messages(含全部历史)POST 给智谱 chat API,返回 content。
所以 chat_app.py 的本质 = 实验三十的 chatbot.py + Streamlit 外壳 。业务逻辑(call_llm、维护 messages 历史)一模一样,Streamlit 只是把 input()/print() 换成了 st.chat_input/st.chat_message,并用 session_state 解决了"脚本重跑会丢变量"的问题。
4.10 实验三十五的思考题
1. Streamlit 是如何实现状态保持的?st.session_state 的作用? 每次交互整个脚本重跑,普通变量会重新初始化。session_state 是一个特殊的字典,其内容跨重跑保留,所以把需要持久化的状态(如对话历史)存在里面就不会丢。
2. 如何实现流式输出(逐字显示)? 智谱 API 支持 stream=True,返回的是逐块的数据流。Streamlit 可以用 st.write_stream() 接收一个生成器,逐字渲染。代码里没实现,但原理是:把 API 的流式响应包成生成器,传给 st.write_stream。
3. 如何部署 Streamlit 应用? 最简单的是 Streamlit Community Cloud(免费),把代码推到 GitHub,在 cloud.streamlit.io 连接仓库即可部署,无需服务器。也可以用 Docker 部署到任意服务器。
五、实验三十六:简单 Agent 思路(ReAct)
对应文件:
agent.py。这是五个实验里最复杂也最有含金量的一个,它引入了"函数调用"和"多步循环",让 LLM 从"只会说话"变成"会用工具的 Agent"。概念稍难,我讲得细一点。
5.1 为什么需要 Agent
直接问 GLM-4"最新 iPhone 多少钱",它会瞎编或说"我不知道"------它训练数据有截止日期,且不能联网。问它"123 × 456 等于多少",它也常算错,因为 LLM 本质是"预测下一个 token",不擅长精确算术。
LLM 的强项是语言理解和推理,弱项是实时信息和精确计算。Agent 的思路是:给 LLM 配几个工具函数(搜索、计算),让它自己判断该不该用、用哪个、参数填什么。这样 LLM 的"语言理解" + 工具的"实时/精确能力"结合,能力就扩展了。
普通 LLM 应用是"用户问 → LLM 答"一条直线;Agent 是"用户问 → LLM 思考要不要用工具 → 用工具 → 看结果 → 再思考 → ... → 答",中间多了一层"决策"和"行动"。
5.2 🔑 函数调用(Function Calling)机制
这是本实验的核心,必须彻底搞懂。
关键认知:LLM 本身不能执行任何代码,它只会输出文本。 函数调用是一个约定,流程是这样的:
1. 你把"工具说明书"(tools)随请求一起发给 LLM
2. LLM 不直接答,而是输出一个特殊结构 tool_calls:
"我想调 search 函数,参数 query='iphone价格'"
3. 你的代码解析这个结构,真正执行本地 search() 函数,拿到结果
4. 你把结果以 role="tool" 的消息喂回 LLM
5. LLM 看到结果,再生成最终自然语言回答
LLM 只负责"决定调什么",真正执行的是你的代码。 这点很多人搞反,以为 LLM 自己调函数。它没有执行能力,它只是输出一段结构化文本告诉你"它想调什么",你来执行。
这个机制的好处是:安全(你控制执行哪些函数、参数怎么过滤)+ 灵活(工具可以是任意 Python 函数,甚至再调一次 LLM)。
5.3 ReAct = Reason + Act 交替
ReAct(Reason + Act)就是上面这个过程的循环版:LLM 可能要调多次工具才能答。
用一个具体例子走一遍 agent.py 的流程(用户问"最新 iPhone 多少钱"):
第1步 Reason + Act: LLM 收到问题 → 决定要搜索 → 返回 tool_calls=[search(query="iphone")]
你的代码执行 search("iphone") → "iPhone 15 Pro 起售价 7999 元..."
把结果以 role="tool" 喂回 LLM
第2步 Reason: LLM 看到搜索结果,不需要再调工具 → 直接返回文本答案
"最新 iPhone 15 Pro 起售价 7999 元"
循环结束(因为第2步没有 tool_calls 了)
第 1 步 LLM"决定行动"(调 search),代码执行后把结果喂回去;第 2 步 LLM"看到结果直接回答"。run_agent() 里的 while 循环就是让这个"思考→行动→观察"能转多圈,所以叫 Agent(能自主多步行动)而不只是"函数调用"。
5.4 两个本地工具函数
python
def search(query):
mock_results = {
"iphone": "iPhone 15 Pro 起售价 7999 元,iPhone 15 Pro Max 起售价 9999 元。",
"天气": "请使用天气查询功能获取实时天气信息。",
"python": "Python 3.12 于 2023 年 10 月发布,是最新稳定版本。",
"pytorch": "PyTorch 2.1 于 2023 年 10 月发布,支持 Flash Attention 2。",
}
for key, result in mock_results.items():
if key in query.lower():
return {"query": query, "result": result, "source": "模拟搜索"}
return {
"query": query,
"result": f"关于「{query}」的模拟搜索结果:这是一个模拟的搜索返回...",
"source": "模拟搜索"
}
search 是模拟搜索:mock_results 是预设答案字典,循环看 query 里含哪个关键词就返回对应结果,都没有就返回通用模拟文本。实际应用这里会换成真实搜索 API(SerpAPI、百度搜索),但用模拟数据能让你专注理解 ReAct 流程,不用注册额外服务,也不会因为网络问题干扰学习。
python
def calculate(expression):
try:
allowed = set('0123456789+-*/.() ')
if not all(c in allowed for c in expression):
return {"error": "表达式包含不允许的字符"}
result = eval(expression)
return {"expression": expression, "result": result}
except Exception as e:
return {"error": f"计算失败: {e}"}
calculate 是安全计算器。注意这两行:
python
allowed = set('0123456789+-*/.() ') # 白名单字符
if not all(c in allowed for c in expression): # 有白名单外的字符就拒绝
return {"error": "表达式包含不允许的字符"}
result = eval(expression) # 通过检查才 eval
为什么要白名单? eval() 能执行任意 Python 代码,非常危险。如果 LLM 传进来 __import__('os').system('rm -rf /'),直接 eval 会删你硬盘。白名单只放行数字和运算符,挡掉所有字母和下划线,这是用 eval 时必须做的安全措施。
这其实引出一个更广的话题:LLM 输出永远不可信。LLM 可能因为提示词注入或自身幻觉,输出你意料之外的内容。任何把 LLM 输出传给执行器(eval、os.system、subprocess)的地方,都要做输入校验。这是 AI 应用安全的第一课。
5.5 工具描述 tools:给 LLM 看的"说明书"
python
tools = [
{
"type": "function",
"function": {
"name": "search",
"description": "搜索互联网获取最新信息。当用户询问实时信息、最新新闻、价格等时使用。",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "搜索关键词"
}
},
"required": ["query"]
}
}
},
{
"type": "function",
"function": {
"name": "calculate",
"description": "执行数学计算。当用户需要数值计算时使用。",
"parameters": {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "数学表达式,如 '2 + 3 * 4'"
}
},
"required": ["expression"]
}
}
}
]
这个结构是 OpenAI 定的 function calling 规范,智谱兼容。它本质是一份"工具说明书",告诉 LLM 有哪些工具可用、每个工具干什么、参数是什么。
name:函数名,LLM 调用时用这个名字;description:最关键字段,告诉 LLM 这个工具能干什么、什么时候该用。LLM 就是读这个 description 来决定"用户这个问题该不该用这个工具"的。description 写得越清楚,决策越准;parameters:JSON Schema 格式描述参数,required列出必填参数。
写 description 是一门手艺。比如 search 的 description 写了"当用户询问实时信息、最新新闻、价格等时使用",这就明确告诉 LLM 什么场景该调它。如果只写"搜索互联网",LLM 可能在不需要时也乱调。
5.6 call_llm_with_tools():带工具的 API 调用
python
def call_llm_with_tools(messages):
payload = {
"model": "glm-4-flash",
"messages": messages,
"tools": tools,
"tool_choice": "auto",
"temperature": 0.7
}
response = requests.post(url, headers=headers, json=payload, timeout=30)
response.raise_for_status()
return response.json()['choices'][0]['message']
和之前所有 call_llm 几乎一样,唯一区别是 payload 多了两个键:
"tools": tools:带上工具说明书,LLM 才知道有工具可用;"tool_choice": "auto":让 LLM 自己决定用不用工具、用哪个。也可以设成"none"(强制不用工具,纯对话)或指定某个函数名(强制用某个工具)。
另一个区别是返回整个 message 对象 (不只 content),因为要检查里面有没有 tool_calls 字段------有就说明 LLM 想调工具,没有就说明它直接给答案了。
5.7 run_agent():ReAct 核心循环逐行拆
这是本实验的灵魂函数,逐行看:
python
def run_agent(user_input):
messages = [{"role": "user", "content": user_input}] # ① 初始只有用户问题
step = 0
while step < MAX_STEPS: # ② 最多5步,防死循环
step += 1
response = call_llm_with_tools(messages) # ③ 调 LLM(带工具说明书)
if not response.get('tool_calls'): # ④ 没 tool_calls = LLM 直接答了
return response['content'] # 返回最终答案,结束
messages.append(response) # ⑤ 把"我想调工具"这条加入历史
for tool_call in response['tool_calls']: # ⑥ 可能要调多个工具,逐个处理
func_name = tool_call['function']['name'] # ⑦ 取函数名 "search"
args = json.loads(tool_call['function']['arguments']) # ⑧ 参数 JSON 串 → dict
result = globals()[func_name](**args) # ⑨ 实际执行本地函数!
messages.append({ # ⑩ 结果以 role="tool" 喂回 LLM
"role": "tool",
"tool_call_id": tool_call['id'],
"content": json.dumps(result, ensure_ascii=False)
})
return "Agent 达到最大步数限制,无法完成任务" # ⑪ 转5次还没完,放弃
逐个标号解释:
- ①
messages初始只有一条用户消息,没有 system 消息(这个 Agent 不需要角色设定); - ②
MAX_STEPS = 5是安全阀,防止 LLM 陷入"一直调工具"的死循环。Agent 比普通 LLM 应用多一个风险:LLM 可能在工具结果不满意时反复调,没有上限会无限转; - ③ 每轮都把完整
messages(含之前的工具调用和结果)发给 LLM,让它基于全部上下文决策; - ④
response.get('tool_calls')是循环的退出条件:LLM 返回的 message 里如果有tool_calls字段,说明它想调工具;没有就说明它已经能直接回答了,返回content结束; - ⑤ 把 LLM 这条"我想调工具"的 assistant 消息加入历史。注意这条消息里没有
content,只有tool_calls,但必须加入,因为 API 要求 tool 消息要能通过tool_call_id关联到对应的 assistant 消息; - ⑥ 一个
tool_calls列表里可能有多个工具调用(LLM 可以一次决定调多个工具),逐个处理; - ⑦
func_name是 LLM 想调的函数名,比如"search"; - ⑧
arguments是一个 JSON 字符串(LLM 输出的),json.loads解析成 dict,比如{"query": "iphone"}; - ⑨ 最精妙的一行,下面专门讲;
- ⑩ 把工具执行结果以
role="tool"的消息加入历史,tool_call_id用来关联是哪次 tool_call 的结果,content是结果的 JSON 字符串; - ⑪ 如果转了 5 圈还没结束,返回兜底文案。
5.8 globals()[func_name](**args) 这行的精妙
python
result = globals()[func_name](**args)
拆开看:
globals()返回当前模块所有全局变量的字典,键是名字、值是对象。因为search和calculate是在模块顶层定义的,所以globals()['search']就是search函数本身,globals()['calculate']就是calculate函数本身;**args是 dict 解包成关键字参数:如果args = {"query": "iphone"},那么f(**args)等价于f(query="iphone");- 合起来就是:根据 LLM 说的函数名,自动调对应的本地函数,并传入 LLM 指定的参数。
好处是省得写一长串 if-elif:
python
# 不用 globals() 就得这样写
if func_name == "search":
result = search(**args)
elif func_name == "calculate":
result = calculate(**args)
else:
result = {"error": "未知函数"}
用 globals() 一行搞定,加新工具时也不用改这段代码。代价是安全性:如果 LLM 输出了一个恶意函数名(比如 eval),globals()['eval'] 也能拿到。生产环境应该用白名单字典 {"search": search, "calculate": calculate} 代替 globals(),只暴露允许的函数。这个实验用 globals() 是为了简洁,学习时知道这个权衡即可。
5.9 用真实例子走一遍 messages 的演变
这是理解 ReAct 最有效的方式------看 messages 列表在循环中怎么一步步变长。用户问"最新 iPhone 多少钱":
初始: messages = [ {user: "最新iPhone多少钱"} ]
── 第1步 ──
③ 调 LLM → LLM 决定要搜索,返回:
response = {
role: "assistant",
tool_calls: [{
id: "call_1",
function: {name: "search", arguments: '{"query": "iphone"}'}
}]
}
④ 有 tool_calls → 不退出
⑤ messages.append(response)
→ messages = [user, assistant(tool_calls)]
⑦⑧ func_name = "search", args = {"query": "iphone"}
⑨ result = search(query="iphone")
= {result: "iPhone 15 Pro 起售价 7999 元..."}
⑩ messages.append({role: "tool", tool_call_id: "call_1", content: '{"result":"iPhone...7999元"}'})
→ messages = [user, assistant(tool_calls), tool(搜索结果)]
── 第2步 ──
③ 再调 LLM(这次它能看到搜索结果了)
→ LLM 不需要再调工具,直接返回:
response = {role: "assistant", content: "最新 iPhone 15 Pro 起售价 7999 元,Pro Max 起售价 9999 元。"}
④ 没有tool_calls → return content → 循环结束 ✅
看懂这个例子,ReAct 就彻底懂了:第 1 步 LLM"决定行动"(调 search),代码执行后把结果喂回去;第 2 步 LLM"看到结果直接回答"。while 循环让这个"思考→行动→观察"能转多圈,处理需要多步推理的复杂问题。
5.10 和实验三十二的区别
实验三十二(天气助手)也用了函数调用,但只调一次函数就答了,没有循环。实验三十六加了 while 循环,允许多步推理。这个差别看起来小,意义很大:
- 实验三十二:用户问天气 → 调一次天气函数 → 答。一问一答一工具,直线流程;
- 实验三十六:用户问"比较北京和上海今天哪个更热" → 调天气函数查北京 → 调天气函数查上海 → 比较两个结果 → 答。需要多步,每步可能调不同工具。
这就是"函数调用"和"Agent"的分水岭:能多步自主决策和行动的才叫 Agent。现在的 LangChain Agent、AutoGPT、各种 AI 助手,底层都是这个 ReAct 循环的扩展(加更多工具、更复杂的推理、记忆管理等)。
5.11 实验三十六的思考题
1. ReAct 与函数调用有什么区别? 函数调用是单次的"LLM 决定调什么 → 执行 → 喂回";ReAct 是函数调用的循环版,LLM 可以多步"推理→行动→观察"交替,直到能直接回答。ReAct 强调推理与行动的交替进行。
2. 如何防止 Agent 陷入无限循环? 代码里用 MAX_STEPS = 5 硬限制最大步数。更高级的做法:让 LLM 自己判断"是否该停止"并在提示词里约束;记录已调用的工具+参数,重复调用时强制停止;设置总 token 消耗上限。
3. 如何让 Agent 使用多个工具? 代码已经支持了------tools 列表里放多个工具描述,for tool_call in response['tool_calls'] 循环处理一次返回的多个 tool_call。加新工具只需:写一个本地函数 + 在 tools 里加一条描述,run_agent 不用改。这就是 globals() 那行的扩展性好处。
六、实验三十七:多模态入门(图像识别)
对应文件:
recognize.py。这个实验比 36 简单,核心就一个新概念:图片怎么传给 API。涉及 base64 编码和多模态消息格式。
6.1 多模态模型是什么
之前所有实验用的都是纯文本模型(glm-4-flash),输入输出都是文字。多模态模型(如智谱 glm-4v、OpenAI GPT-4V)能同时处理文本和图片,输入一张图加一句话,输出对图片的描述或回答。
本实验做一个"拍照识物"工具:用户给一张图片路径,程序把图片发给 glm-4v,AI 返回对图片内容的描述。
6.2 为什么需要 base64 编码
图片是二进制 数据,但 HTTP 请求的 JSON 里只能放文本。这就有矛盾:怎么把二进制图片塞进 JSON?
解决方案是 base64 编码 :把任意二进制数据转成纯文本字符(只用 A-Za-z0-9+/ 这 64 个字符加补位的 =),这样就能塞进 JSON。API 收到字符串后再 base64 解码还原成二进制图片。
我演示过这个转换:
原始二进制: b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR'
直接打印有乱码/不可见字符,没法放进 JSON
base64 编码后: iVBORw0KGgoAAAANSUhEUg==
长度: 24 全是可见文本字符: True
→ 这串文本可以安全放进 JSON 传给 API
base64 的代价是体积膨胀约 33%(3 字节二进制编码成 4 字符文本),但对小图片无所谓,大图片一般用 URL 传而不是 base64。
6.3 多模态消息格式
这是和之前所有实验最大的区别。之前消息的 content 都是字符串:
python
{"role": "user", "content": "你好"} # ← 字符串
多模态消息的 content 变成数组,混合图片和文本两种元素:
python
{"role": "user", "content": [
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,iVBORw0K..."}}, # 图片
{"type": "text", "text": "请描述这张图片"} # 文字
]}
- 数组里每个元素有
type字段,"image_url"是图片,"text"是文字; - 图片的
url用 data URL 格式:data:{MIME类型};base64,{base64数据},把数据直接嵌在 URL 里,API 能直接解析; - MIME 类型告诉 API 图片格式(
image/jpeg、image/png等),它才知道怎么解码; - 模型也要从
glm-4-flash(纯文本)换成glm-4v-flash(v = vision,支持视觉)。
6.4 代码拆解:image_to_base64()
python
def image_to_base64(image_path):
with open(image_path, 'rb') as f: # 'rb' = 二进制读
return base64.b64encode(f.read()).decode('utf-8') # 编码 → 转 str
就两行:open(..., 'rb') 以二进制模式读图片('rb' = read binary),base64.b64encode 把二进制编码成 base64 字节串,.decode('utf-8') 转成普通字符串(因为 JSON 要的是 str 不是 bytes)。
6.5 代码拆解:get_image_mime_type()
python
def get_image_mime_type(image_path):
ext = os.path.splitext(image_path)[1].lower()
mime_map = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.bmp': 'image/bmp',
'.webp': 'image/webp',
}
return mime_map.get(ext, 'image/jpeg')
根据文件扩展名查字典返回 MIME 类型,默认 image/jpeg。os.path.splitext 把路径拆成 (名字, 扩展名),取 [1] 就是扩展名(如 .png)。MIME 类型告诉 API 图片格式,它才知道怎么解码 base64 还原。
6.6 代码拆解:recognize_image() 核心函数
python
def recognize_image(image_path, prompt="请详细描述这张图片中的物体、场景、颜色等信息。"):
if not os.path.exists(image_path):
return f"图片文件不存在: {image_path}"
base64_str = image_to_base64(image_path)
mime_type = get_image_mime_type(image_path)
messages = [
{
"role": "user",
"content": [
{
"type": "image_url",
"image_url": {"url": f"data:{mime_type};base64,{base64_str}"}
},
{
"type": "text",
"text": prompt
}
]
}
]
payload = {
"model": "glm-4v-flash",
"messages": messages,
"temperature": 0.7,
"max_tokens": 500
}
try:
response = requests.post(url, headers=headers, json=payload, timeout=60)
response.raise_for_status()
data = response.json()
return data['choices'][0]['message']['content']
except requests.exceptions.HTTPError as e:
return f"API错误(HTTP {e.response.status_code}): {e.response.text}"
except Exception as e:
return f"识别失败: {e}"
五步对应前面讲的概念:
- 校验文件存在;
image_to_base64把图片转 base64 字符串;get_image_mime_type取 MIME 类型;- 构造多模态消息:
content是数组,第一个元素是图片(data URL),第二个是文字提示词; - 用
glm-4v-flash视觉模型调 API,取返回的文本描述。
和之前文本对话唯一的区别就是 content 从字符串变成"图片+文本"数组,模型换成 vision 版。 其余 requests.post + 取 choices[0].message.content 完全一样。这就是我在开头说的"底层都是 requests.post,区别只在 payload 怎么构造"。
timeout=60 比文本对话的 30 秒长,因为图片识别通常更慢。错误处理也细一点:HTTPError 单独捕获,把 API 返回的错误信息带出来(比如图片太大、格式不支持等),方便排查。
6.7 代码拆解:main() 交互
python
def main():
while True:
image_path = input("请输入图片路径: ").strip()
if image_path.lower() in ('exit', 'quit', '退出'):
break
if not image_path:
continue
image_path = image_path.strip('"').strip("'")
if not os.path.exists(image_path):
print(f"文件不存在: {image_path}")
continue
file_size = os.path.getsize(image_path) / 1024
print(f"图片: {image_path} ({file_size:.1f} KB)")
result = recognize_image(image_path)
print(result)
交互循环:输入图片路径 → 校验 → 调 recognize_image → 打印描述。
有个贴心细节 image_path.strip('"').strip("'"):去掉路径首尾的引号。因为用户从文件管理器拖拽路径到命令行,Windows 常自动加引号(路径有空格时),不去掉 os.path.exists 会找不到文件。这种小细节是实际使用中踩坑后才加的。
6.8 实验三十七的思考题
1. 多模态模型的输入限制有哪些? 图片大小有上限(通常几 MB),格式有限制(jpg/png/gif/bmp/webp),分辨率太高可能被压缩或拒绝。base64 编码后体积膨胀 33%,要注意 API 对请求体大小的限制。一次能传的图片数量也有限。
2. 如果图片中有多个物体,模型能准确识别吗? 能识别多个物体,但准确率取决于物体大小、遮挡、清晰度。可以加提示词引导,比如"请列出图片中所有物体并分别描述",或"图片中有没有 X?"。复杂场景可能需要专门的目标检测模型(如 YOLO)而非通用 VLM。
3. 如何实现实时摄像头拍照识别? 用 OpenCV(cv2.VideoCapture)捕获摄像头画面,按空格拍照保存成临时文件,再调 recognize_image。或者把摄像头帧直接转 base64(不落盘),实时传给 API。Streamlit 有 st.camera_input 组件可以直接拍照。
七、实验三十八:综合实战------AI 翻译+润色工具
对应文件:
translation_app.py。这是阶段五的收官实验,把实验三十五的 Streamlit 和实验三十一的提示词工程综合起来,做一个实用的 AI 翻译+润色网页工具。没有新概念,重点看两件事:提示词怎么针对不同功能设计、Streamlit 怎么做多栏布局。
7.1 功能需求
这个工具要具备:
- 文本输入框(支持多行);
- 功能选择:翻译(选目标语言)或润色(选风格:学术、商务、口语等);
- 点击按钮后调 LLM 处理,显示结果;
- 历史记录保存在
session_state,显示在侧边栏; - 界面美观,响应式。
7.2 提示词工程:两个 prompt 构造函数
这是本实验的重点之一------针对不同任务设计不同提示词。
翻译提示词:
python
def translate_prompt(text, target_language):
return (
f"请将以下文本翻译成{target_language}。\n"
f"要求:翻译准确、自然流畅,保持原文的语气和风格。\n"
f"只输出翻译结果,不要添加解释。\n\n"
f"原文:\n{text}"
)
润色提示词:
python
def polish_prompt(text, style):
style_desc = {
"学术": "学术风格,用词严谨、逻辑清晰,适合论文或学术报告",
"商务": "商务风格,专业正式、简洁有力,适合商务邮件或报告",
"口语": "口语风格,自然亲切、通俗易懂,适合日常交流",
"文学": "文学风格,用词优美、富有文采,增强文章的感染力",
"新闻": "新闻风格,客观中立、信息准确,适合新闻报道或公告",
}
desc = style_desc.get(style, "通用润色风格")
return (
f"请将以下文本润色为{desc}。\n"
f"要求:保持原文的核心意思不变,改善表达方式,修正语法错误。\n"
f"只输出润色结果,不要添加解释。\n\n"
f"原文:\n{text}"
)
提示词工程有几个要点,这是贯穿实验二十九到三十八的功夫:
第一,明确任务。 "翻译成 X 语言" / "润色为 X 风格",让 LLM 知道要干什么。
第二,明确要求。 翻译要"准确、自然流畅、保持语气风格";润色要"保持核心意思不变、改善表达、修正语法"。约束越具体,输出越可控。
第三,"只输出结果,不要添加解释"。 这句很关键。不加这句,LLM 经常会输出"好的,以下是翻译结果:......希望对您有帮助!"这种废话,影响后续处理。加了这句,输出干净利落。
第四,style_desc 字典把短词展开成详细描述。 用户选"学术",但直接告诉 LLM "润色为学术风格"太笼统,LLM 不知道具体要什么。展开成"学术风格,用词严谨、逻辑清晰,适合论文或学术报告",LLM 就清楚多了。给 LLM 越具体的指令,输出越符合预期------这是提示词工程的核心心法。
7.3 call_llm():单次调用,无历史
python
def call_llm(prompt):
payload = {
"model": "glm-4-flash",
"messages": [{"role": "user", "content": prompt}],
"temperature": 0.3,
"max_tokens": 1000
}
response = requests.post(url, headers=headers, json=payload, timeout=60)
response.raise_for_status()
return response.json()['choices'][0]['message']['content']
和之前的 call_llm 一样,但注意:只传单条消息 ([{"role": "user", "content": prompt}]),没有历史。因为翻译/润色是单次任务,不需要多轮上下文,每次都是独立的"输入文本 → 输出结果"。
temperature=0.3 低温度求准确------翻译要忠实原文,不希望 LLM 发挥创意乱改。max_tokens=1000 给足空间输出长文本。
7.4 Streamlit 布局:分栏
python
col1, col2 = st.columns([1, 3])
with col1:
st.subheader("功能选择")
mode = st.radio("选择操作", ["🌐 翻译", "✨ 润色"], index=0)
if mode == "🌐 翻译":
target_lang = st.selectbox(
"目标语言",
["英语", "日语", "韩语", "法语", "德语", "俄语", "西班牙语", "中文"],
index=0
)
else:
style = st.selectbox(
"润色风格",
["学术", "商务", "口语", "文学", "新闻"],
index=0
)
with col2:
st.subheader("输入文本")
text = st.text_area(
"请输入要处理的文本",
height=200,
placeholder="在此输入要翻译或润色的文本..."
)
st.columns([1, 3]) 把主区域分成左右两栏,宽度比 1:3。左栏窄,放功能选择;右栏宽,放文本输入和结果。
条件渲染 是这里的关键:if mode == "🌐 翻译": 显示语言下拉框,else: 显示风格下拉框。同一块区域根据用户选的功能显示不同控件。这是 Streamlit 的常态------你用普通 Python 的 if-else 控制显示什么,它会自动处理 UI 的显示/隐藏。
st.radio 是单选按钮组,st.selectbox 是下拉选择框,st.text_area 是多行文本输入框。placeholder 是输入框为空时的提示文字。
7.5 处理按钮与结果展示
python
if st.button("🚀 开始处理", type="primary", use_container_width=True):
if not text.strip():
st.warning("请输入文本内容")
else:
with st.spinner("AI 正在处理..."):
try:
if mode == "🌐 翻译":
prompt = translate_prompt(text, target_lang)
label = f"翻译 → {target_lang}"
else:
prompt = polish_prompt(text, style)
label = f"润色 → {style}"
result = call_llm(prompt)
from datetime import datetime
st.session_state.history.append({
'type': label,
'input': text,
'output': result,
'time': datetime.now().strftime("%H:%M:%S")
})
st.subheader("处理结果")
st.success("处理完成!")
st.text_area("结果", result, height=200, key="result_area")
st.code(result, language=None)
except Exception as e:
st.error(f"处理失败: {e}")
流程:
st.button("🚀 开始处理", type="primary"):主按钮,type="primary"让它高亮显眼;- 点击后先校验文本非空;
st.spinner("AI 正在处理...")显示加载提示;- 根据模式构造对应的 prompt(翻译或润色),调
call_llm; - 把这次处理存入
session_state.history(含类型标签、输入、输出、时间戳); - 显示结果:
st.success成功提示,st.text_area结果框,st.code提供可复制的代码块。
from datetime import datetime 在函数内部导入,是利用 Python 允许局部导入的特性。虽然不推荐(一般放文件头),但这里只为用一次时间戳,无伤大雅。
7.6 侧边栏历史记录
python
if 'history' not in st.session_state:
st.session_state.history = []
with st.sidebar:
st.header("📜 历史记录")
if st.button("🗑️ 清空历史", use_container_width=True):
st.session_state.history = []
st.rerun()
st.divider()
for i, record in enumerate(reversed(st.session_state.history)):
st.markdown(f"**{record['type']}** · {record['time']}")
st.text(record['input'][:30] + "..." if len(record['input']) > 30 else record['input'])
st.caption(record['output'][:50] + "..." if len(record['output']) > 50 else record['output'])
st.divider()
和实验三十五用 session_state.messages 存对话历史是同一个套路:if 'history' not in st.session_state: 只在第一次初始化,之后跨重跑保留。
reversed(st.session_state.history) 倒序遍历,最新的记录显示在最上面。每条记录显示类型标签、时间、输入预览(前 30 字)、输出预览(前 50 字),用 st.divider() 分隔。text[:30] + "..." if len(text) > 30 else text 是 Python 的三元表达式,超长就截断加省略号。
7.7 实验三十八的思考题
1. 如何提高用户体验,比如流式输出? 用 API 的 stream=True,把流式响应包成生成器,用 st.write_stream(generator) 逐字渲染。用户能看到 AI"边想边写",体验比等一个完整结果好很多。
2. 如何让用户自定义提示词? 加一个"高级模式":提供文本框让用户自己写提示词模板,用 {text} 占位符表示用户输入位置,运行时 prompt = user_template.format(text=text)。要小心提示词注入,但工具类应用一般用户自己用,风险可控。
3. 如何添加文本对比功能(原文与译文对比)? 用 st.columns(2) 把原文和译文并排显示,或用 st.diff(如果有的话)。更高级可以用 difflib 算差异高亮,但翻译前后文本差异大,逐字 diff 意义不大,并排展示更实用。
八、横向对比与知识串联
五个实验学完,回头横向看,能发现很多共性和递进关系。这一节把它们串起来,形成体系。
8.1 五个实验对比总表
| 实验 | 文件 | 本质 | 核心新知 | API 用到 | 形态 |
|---|---|---|---|---|---|
| 34 | rag.py |
检索文档块 + LLM 基于块回答 | RAG 流程、embedding 检索、余弦相似度 | embedding + chat | 命令行 |
| 35 | chat_app.py |
命令行聊天 → 网页 | session_state 跨重跑保状态 | chat | Web |
| 36 | agent.py |
LLM 自主调工具,多步循环 | 函数调用机制、ReAct 循环 | chat + tools | 命令行 |
| 37 | recognize.py |
图片转 base64 → 视觉模型识图 | base64 编码、多模态 content 数组 | vision | 命令行 |
| 38 | translation_app.py |
Streamlit + 提示词工程做工具 | 综合运用(无新知) | chat | Web |
8.2 共同的底层模式
我在开头说过,这五个实验底层全是 requests.post() 调智谱 API,区别只在 payload 构造和返回解析。这里展开看每个实验的 payload 差异:
实验 34 RAG 调两种 API:
- embedding API:
payload = {"model": "embedding-2", "input": text},返回data[0]['embedding'](向量); - chat API:
payload = {"model": "glm-4-flash", "messages": [...]},返回choices[0].message.content(文本)。
实验 35 Streamlit 调 chat API,payload 和 34 的 chat 部分一模一样,只是外面套了 Streamlit 组件。
实验 36 Agent 调 chat API,payload 多了 "tools": tools, "tool_choice": "auto",返回要检查 message.tool_calls(可能想调工具)。
实验 37 多模态 调 chat API,但 messages 的 content 从字符串变成数组(图片+文本),模型换成 glm-4v-flash。
实验 38 翻译润色 调 chat API,payload 和 35 一样,只是 prompt 是动态构造的(翻译或润色)。
画成一张图:
所有实验 → requests.post(智谱API, headers, payload) → 解析 JSON
↑
payload 的差异:
- 模型名:glm-4-flash / embedding-2 / glm-4v-flash
- messages.content:字符串 / [图片+文本] 数组
- tools:无 / 有工具说明书
- tool_choice:无 / auto
看透这层,以后遇到任何新的 AI 应用代码,第一件事就是看它的 payload 怎么构造,就能快速理解它在干什么。
8.3 递进关系图
29 对联(单次API) → 30 多轮对话(消息列表) → 31 提示词工程(角色/思维链)
↓
32 函数调用(单次工具) → 36 ReAct Agent(多步工具循环) 33 embedding检索
↓ ↓
↓ 34 RAG(检索+生成)
↓
35 Streamlit(命令行→网页) → 38 综合实战(35+提示词工程)
37 多模态(图片输入,相对独立)
- 29→30→31 是"怎么和 LLM 对话"的进阶:单次→多轮→提示词技巧;
- 32→36 是"让 LLM 用工具"的进阶:单次函数调用→多步 Agent;
- 33→34 是"让 LLM 用你的数据"的进阶:纯检索→检索+生成;
- 35→38 是"做网页"的进阶:纯聊天→功能工具;
- 37 引入视觉,相对独立。
8.4 三个反复出现的核心概念
提示词工程 :从 29 到 38 贯穿始终。29 的对联 prompt、31 的角色设定、34 的"基于上下文回答"、36 的工具 description、38 的翻译/润色 prompt------都是同一门功夫的不同应用。核心心法就一句:给 LLM 越具体、越明确的指令,输出越符合预期。
消息列表(messages) :30 引入,之后每个实验都用。LLM 没有记忆,"多轮对话"的本质是你每次把完整历史(一个消息列表)传过去。消息有不同 role:system(角色设定)、user(用户说的)、assistant(AI 说的)、tool(工具返回的结果)。理解这个列表结构,就理解了所有对话型 AI 应用的数据流。
状态管理 :命令行版用普通变量+循环;Streamlit 版用 session_state。本质都是"把需要跨轮次保留的数据存在某个持久的地方"。这个思维在 Web 开发里通用(cookie、session、数据库都是同一回事的不同实现)。
九、学习收获与心得
学完这五个实验,我有几个超出具体代码的收获,写下来沉淀一下。
9.1 对 LLM 应用整体认知的建立
之前对"AI 应用"的认知是模糊的,觉得很高深。学完发现,一个 LLM 应用的骨架其实很简单:
用户输入 → 构造 prompt → 调 API → 解析返回 → 展示给用户
所有花活都在"构造 prompt"这一步:RAG 在这里插入检索到的文档块,Agent 在这里插入工具说明和之前的工具结果,多模态在这里插入图片。骨架不变,变的只是往 prompt 里塞什么。
这个认知建立后,看 LangChain、LlamaIndex 这些框架的源码就不怵了------它们只是把"构造 prompt → 调 API → 解析返回"这个过程封装得更优雅、组合得更灵活,底层逻辑和我这五个实验一模一样。
9.2 提示词工程是"性价比最高"的技能
五个实验里,真正影响输出质量的,不是用了什么高级框架,而是提示词写得好不好。同样一个翻译任务,"翻译成英语"和"请将以下文本翻译成英语,要求准确自然流畅、保持原文语气,只输出结果不要解释"------效果天差地别。
而且提示词工程几乎零成本:不用训练、不用 GPU、不用改模型,只是改一段文字。这是普通开发者能立刻上手、立刻见效的 AI 技能。框架会换、模型会迭代,但"怎么给 LLM 下指令"这门手艺长期有用。
我的体会是,写好提示词要注意:明确任务、明确要求、明确输出格式、给足上下文、用具体描述代替笼统词汇。这些在 38 的 style_desc 里体现得最清楚。
9.3 工程化思维的训练
这五个实验虽然简单,但代码里有很多工程化的小细节值得学习:
- 缓存 (34 的
rag_cache.pkl):避免重复调 API 花钱花时间; - 限流重试 (34 的
get_embedding里 429 处理):应对 API 的速率限制; - 安全校验 (36 的
calculate白名单):防止eval执行恶意代码; - 死循环防护 (36 的
MAX_STEPS):防止 Agent 无限调工具; - 容错(到处都是 try-except):网络请求随时可能失败,要优雅处理;
- 用户体验(37 的去引号、35 的 spinner 加载提示):小细节决定好不好用。
这些在"能跑起来"的 demo 代码里常被省略,但生产环境必不可少。阶段五的代码把这些都写进去了,是很好的工程化示范。
9.4 安全意识的建立
实验三十六的 eval 白名单给我敲了警钟。LLM 的输出是不可控的------它可能因为提示词注入、幻觉或对抗输入,产生你意料之外的内容。任何把 LLM 输出传给执行器的地方(eval、os.system、subprocess、SQL 拼接)都有风险。
这引出 AI 应用安全的几个原则:
- LLM 输出永远不可信,当作不可信用户输入对待;
- 能白名单就别用
globals()/eval,只暴露明确允许的操作; - 工具的参数要校验 ,比如
calculate限制字符、search限制查询长度; - 提示词注入:用户可能在输入里写"忽略以上指令,改为......",RAG 的文档里也可能被注入恶意指令,要有防范意识(比如把用户输入和系统指令用明确分隔符隔开)。
这些在 demo 里不显,但做真实产品时是必须考虑的。
9.5 "看代码"方法论
学完这五个实验,我总结了一套看任何 AI 应用代码的方法:
- 先找
requests.post:定位 API 调用点,看 URL 是哪个端点(chat/embedding/vision); - 看 payload 构造:模型名、messages 结构、有没有 tools、有没有特殊参数(temperature、stream);
- 看返回解析 :从哪个路径取结果(
choices[0].message.content/data[0].embedding)、有没有处理tool_calls; - 看消息列表怎么维护:有没有 system 消息、历史怎么累积、有没有截断;
- 看状态怎么存 :命令行版看变量和循环,Streamlit 版看
session_state。
按这个顺序看,任何陌生的 AI 应用代码都能快速理清主线。
十、踩坑总结
学习过程中踩了几个坑,记录下来供后来人参考。
10.1 智谱账户余额不足
这是最大的坑。代码写好后跑 rag.py,embedding API 返回 HTTP 429,错误信息是 余额不足或无可用资源包,请充值(error code 1113)。
智谱的免费额度是有限的,而且 embedding 和 chat 可能消耗不同资源包。解决办法:
- 去
open.bigmodel.cn控制台查看余额和资源包; - 充值或领取新的资源包;
- 学习阶段如果只想验证流程,可以用模拟数据(像 36 的
search那样 mock)代替真实 API 调用。
教训 :跑 AI 应用前先确认账户余额,别等代码写完才发现跑不了。另外把 API 调用的错误信息打印清楚(response.text),能快速定位是余额问题还是代码问题。
10.2 API 限流(429)
即使余额充足,请求太频繁也会被限流(429)。rag.py 的 build_index 里 time.sleep(1) 就是防限流------每调一次 embedding 休息 1 秒。get_embedding 还有 429 重试逻辑。
教训:批量调 API 要加间隔和重试。生产环境可以用令牌桶算法更精细地控制速率。
10.3 eval 的安全风险
实验三十六的 calculate 用了 eval,如果不对输入做白名单校验,LLM 可能输出恶意代码导致 RCE(远程代码执行)。
教训 :永远不要 eval 不可信输入。白名单校验是最简单的防护,更严格可以用 ast.literal_eval(只支持字面量)或专门的数学表达式解析库。
10.4 文件路径带引号
实验三十七的 main 里 image_path.strip('"').strip("'") 处理路径首尾引号。Windows 拖拽路径到命令行会自动加引号(路径含空格时),不去掉 os.path.exists 找不到文件。
教训:处理用户输入的路径要考虑各种粘贴/拖拽的格式,去掉首尾引号和多余空白。
10.5 Streamlit 的 set_page_config 位置
st.set_page_config 必须是脚本里第一个 Streamlit 命令,放在任何其他 st 调用之前,否则配置不生效(会有警告)。我一开始把它放在 st.title 之后,页面标题没设上。
教训:看文档注意"必须在最前面"这种约束,Streamlit 文档里写了但容易忽略。
10.6 编码问题
读文档要用 open(..., encoding='utf-8'),否则 Windows 默认 GBK 编码读中文文档会乱码。rag.py 里都写了 encoding='utf-8',但自己写代码时容易忘。
教训 :处理中文文件一律显式指定 encoding='utf-8',别依赖系统默认编码。
十一、后续学习方向
阶段五学完,只是入了门。接下来有几个方向可以深入。
11.1 LangChain / LlamaIndex
这五个实验都是裸 requests 调 API,好处是每一步都透明、能看懂。生产项目一般用 LangChain 或 LlamaIndex,它们把"prompt 模板、消息历史、工具调用、向量存储、文档加载"都封装成可组合的组件,代码更简洁、扩展性更强。
建议:先用裸 API 写一遍(就像这五个实验),理解底层逻辑,再学 LangChain,就能看懂它封装了什么、解决了什么问题,而不是黑盒调用。
11.2 向量数据库
实验三十四把向量存在内存列表里,文档一多就慢且占内存。生产环境用向量数据库(Chroma、FAISS、Milvus、Pinecone),支持持久化、近似检索(ANN)、百万级向量。Chroma 最适合入门,几行代码就能替换掉 34 的列表存储。
11.3 真实搜索 API
实验三十六的 search 是模拟的。可以接入 SerpAPI(Google 搜索 API,付费有免费额度)或 DuckDuckGo Search(免费),让 Agent 真能联网。接入后会发现真实搜索结果噪声大,需要让 LLM 做结果筛选,这又是提示词工程的练习。
11.4 部署上线
Streamlit Community Cloud 可以免费部署 Streamlit 应用,把代码推到 GitHub 连接即可。如果想更专业,用 Docker + FastAPI 把 AI 能力做成 API 服务,前端用 React/Vue,就是完整的全栈 AI 产品了。
11.5 提示词工程进阶
这五个实验的提示词都比较基础。进阶可以学:Few-shot(给几个示例引导输出格式)、Chain-of-Thought(让 LLM 写出推理过程再答)、Self-Consistency(多次采样取多数)、ReWOO/Plan-and-Execute 等更复杂的 Agent 范式。推荐 Anthropic 和 OpenAI 官方的提示词工程指南。
11.6 多模态进阶
实验三十七只做了图片识别。可以扩展:视频理解、音频转写、图文混合对话、图片生成(DALL-E/CogView)、OCR + LLM 处理扫描文档等。多模态是当前最活跃的方向之一。
写在最后
阶段五这十个实验,从一行 requests.post 起步,到最后能搭出一个 RAG 问答系统、一个会自主用工具的 Agent、一个多模态识图工具、一个 Streamlit 网页应用------这个进阶曲线设计得非常漂亮,每一步都只引入一个新概念,后一步复用前一步的代码。
我最大的收获不是学会了某个 API 怎么调,而是建立了一套"理解 AI 应用"的思维框架:所有 LLM 应用都是"构造 prompt → 调 API → 解析返回"的变体,花活全在 prompt 构造,状态管理靠消息列表和 session_state,工具调用是 LLM 决策 + 代码执行的协作,安全底线是 LLM 输出不可信。
有了这个框架,以后面对 LangChain、AutoGPT、各种新的 AI 应用,都能快速看透底层在干什么,而不是被花哨的框架和术语唬住。
感谢这套实验的设计者,把复杂的 AI 工程拆成了十个能一步步走完的小台阶。也建议后来者按顺序做、每个都自己写一遍代码、跑一遍(哪怕用模拟数据),别只看不动------AI 应用这门手艺,看懂和会写之间差着十遍亲手敲代码的距离。
以上,与同行者共勉。
本文涉及的完整代码在阶段五实验项目里,基于智谱 AI GLM 系列模型。如需复现,请先在
open.bigmodel.cn注册并获取 API Key,填入.env文件的ZHIPU_API_KEY。Streamlit 应用用streamlit run xxx.py启动。