阶段五_实验34-38_学习总结

阶段五 AI 应用实验学习总结:从 API 调用到 RAG、Agent 与多模态的完整实践

本文是我在阶段五 AI 应用实验中的完整学习总结,涵盖实验三十四到三十八共五个实验。从最基础的大模型 API 调用,一步步走到 RAG 检索增强生成、Streamlit 网页应用、ReAct Agent、多模态图像识别,最后以一个综合实战工具收尾。全文按"原理深挖 → 代码逐行拆解 → 运行流程演示 → 踩坑与思考"的顺序展开,既是学习笔记,也是可以照着复现的教程。

目录


一、前言:为什么写这篇总结

在正式进入实验之前,我的 Python 基础有一些,HTTP API 调用也大致了解,但对"大模型应用到底怎么从零搭起来"始终没有一个完整的、能落地的认知。网上资料要么太浅(只讲怎么调一次 API),要么太深(一上来就 LangChain 全家桶),很难找到一个适合"有一点基础的学生"逐步吃透的路径。

阶段五这套实验设计得很好,它从最简单的"调一次 API 生成对联"起步,每个实验只引入一个新概念,后一个实验复用前一个的代码,形成一条平滑的进阶曲线。到实验三十四至三十八这五个,已经能组合出 RAG、Agent、多模态这些当下最热门的 AI 应用范式。

写这篇总结有三个目的:

  1. 巩固知识:教是最好的学,把每个实验的原理和代码讲清楚,自己就真懂了;
  2. 留一份可复现的记录:以后自己复习或同学问起,直接发链接;
  3. 沉淀一套"看 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 为真,进入处理)。

处理流程:

  1. 先用 st.chat_message("user") 画一个用户气泡,st.write(prompt) 显示用户说的话;
  2. 把用户消息加入 session_state.messages 历史;
  3. 画一个 assistant 气泡,里面用 st.spinner("思考中...") 显示加载提示,调用 call_llm(st.session_state.messages) 拿回复;
  4. 把 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}"

五步对应前面讲的概念:

  1. 校验文件存在;
  2. image_to_base64 把图片转 base64 字符串;
  3. get_image_mime_type 取 MIME 类型;
  4. 构造多模态消息:content 是数组,第一个元素是图片(data URL),第二个是文字提示词;
  5. 用 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}")

流程:

  1. st.button("🚀 开始处理", type="primary"):主按钮,type="primary" 让它高亮显眼;
  2. 点击后先校验文本非空;
  3. st.spinner("AI 正在处理...") 显示加载提示;
  4. 根据模式构造对应的 prompt(翻译或润色),调 call_llm;
  5. 把这次处理存入 session_state.history(含类型标签、输入、输出、时间戳);
  6. 显示结果: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 应用安全的几个原则:

  1. LLM 输出永远不可信,当作不可信用户输入对待;
  2. 能白名单就别用 globals()/eval,只暴露明确允许的操作;
  3. 工具的参数要校验 ,比如 calculate 限制字符、search 限制查询长度;
  4. 提示词注入:用户可能在输入里写"忽略以上指令,改为......",RAG 的文档里也可能被注入恶意指令,要有防范意识(比如把用户输入和系统指令用明确分隔符隔开)。

这些在 demo 里不显,但做真实产品时是必须考虑的。

9.5 "看代码"方法论

学完这五个实验,我总结了一套看任何 AI 应用代码的方法:

  1. 先找 requests.post:定位 API 调用点,看 URL 是哪个端点(chat/embedding/vision);
  2. 看 payload 构造:模型名、messages 结构、有没有 tools、有没有特殊参数(temperature、stream);
  3. 看返回解析 :从哪个路径取结果(choices[0].message.content / data[0].embedding)、有没有处理 tool_calls;
  4. 看消息列表怎么维护:有没有 system 消息、历史怎么累积、有没有截断;
  5. 看状态怎么存 :命令行版看变量和循环,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 启动。

相关推荐
M78佐菲1 小时前
ARM学习笔记(11)
linux·arm开发·笔记·嵌入式硬件·学习
M78佐菲1 小时前
ARM学习笔记(10)
linux·arm开发·笔记·嵌入式硬件·学习
dadaobusi2 小时前
学习:PCIe原子操作
学习
李游Leo2 小时前
HarmonyOS 7 + ArkUI + RelationalStore 学习笔记:本地数据分层存储与状态持久化架构实践【鸿蒙心迹】
笔记·学习·架构·harmonyos
布吉岛的石头2 小时前
Java 程序员第 49 阶段5:BERT 预训练目标 MLM+NSP 的工程含义
java·人工智能·深度学习·bert·transformer
我命由我123452 小时前
经纪人的职业定义
经验分享·笔记·学习·职场和发展·求职招聘·职场发展·学习方法
做cv的小昊3 小时前
【World Model】π0.5:a Vision-Language-Action Model with Open-World Generalization
人工智能·算法·机器学习·大模型·多模态·vla·世界模型
广州山泉婚姻4 小时前
Go微服务落地:服务通信、注册发现完整实现分享
人工智能·深度学习·微服务