← 返回上一页

第12章: Capstone · Enterprise AI Copilot

融合第3/5/7/8章能力:多轮对话 + RAG知识库 + 数据分析Agent + 多模态Vision → 一个完整可运行的企业级智能助手

🎯 项目概览:你要交付什么

面试官看的不是"你会不会 LangChain",而是你能不能把能力变成产品。本章把前面所有章节的精华融合成一个完整可运行的企业级智能助手。 这不是"把四个 demo 拼一起",而是把每个阶段最值钱的能力提炼出来,统一到一个可交付的产品里。

665
行代码
6
核心模块
4
AI能力
3
文件类型
1
文件部署
💬
多轮对话
第3章 · 记忆管理
📚
RAG 检索
第5章 · 知识库问答
🤖
数据分析 Agent
第7章 · 代码生成执行
👁️
Vision 识图
第8章 · 多模态提取
🛠️ 技术栈
Python 3.10+ Streamlit LangChain DashScope (通义千问) ChromaDB PyMuPDF Pandas Pydantic Matplotlib
亮点 1:答案可溯源
RAG 检索返回 sources(page/content),回答输出"结论 + 证据引用"。
企业场景的最低可信要求:不是"能回答",而是"答案能追溯到文档第几页"
亮点 2:可观测可复盘
每次请求显示 意图识别结果 + 置信度,知道模型"想做什么"。
出错时能精确定位:是意图识别错了?还是能力执行错了?
亮点 3:工程化可扩展
单文件部署,复制即可运行。
新增能力只需:1) 加 intent 枚举 2) 加 few-shot 示例 3) 加处理函数
✅ 核心设计思想:先结构化,再回答
任何用户输入先被解析成可执行 JSON 合同(intent/slots/confidence),再由 Router 决定走哪条链路。
好处:可控(知道模型要做什么)、可观测(每步有 trace)、可扩展(新增能力只需加分支)、可兜底(低置信度转人工)。
🎬 演示脚本(3条就够)
场景1 - RAG 问答:上传员工手册 PDF → 问"病假最多几天?" → 系统检索相关段落 → 返回答案 + 引用页码
场景2 - 数据分析:上传销售数据 CSV → 问"统计各类别销量并画条形图" → 系统生成 Pandas 代码 → 执行并返回图表
场景3 - 图片识别:上传发票截图 → 问"这张发票金额是多少?" → Vision 模型识别 → 返回结构化 JSON

🚀 快速开始(3步运行)

📁 项目结构
chapter_12/
├── chatbot.py          # 主入口(复制下方代码)
└── requirements.txt    # 依赖清单
⚡ 安装 & 运行
pip install streamlit langchain langchain-community dashscope chromadb pymupdf pandas pydantic matplotlib
export DASHSCOPE_API_KEY="your_key"
streamlit run chatbot.py
💡 使用示例
数据分析:上传 CSV → 问 "统计各类别数量并画条形图"
RAG 问答:上传 PDF → 问 "文档的主要内容是什么"
图片识别:上传发票 → 问 "这张发票的金额是多少"
普通问答:直接问 "帮我写一首诗"

🏗️ 系统架构:意图识别 → 路由 → 能力执行

整个系统采用管道式架构:用户输入 → 意图识别 → 路由分发 → 能力执行 → 结果返回。 每一步都是独立的,可以单独测试、单独优化、单独替换。

┌─────────────────────────────────────────────────────────────────────────────────┐
│                           Enterprise AI Copilot 架构图                          │
├─────────────────────────────────────────────────────────────────────────────────┤
│                                                                                 │
│  ┌─────────────────┐                                                            │
│  │   用户输入层     │  Streamlit UI: st.chat_input() + st.file_uploader()       │
│  │  (自然语言+文件) │  支持: 文本输入 / CSV上传 / PDF上传 / 图片上传             │
│  └────────┬────────┘                                                            │
│           ↓                                                                     │
│  ┌─────────────────┐  ┌──────────────────────────────────────────────────────┐ │
│  │   意图识别层     │  │ IntentRecognizer (qwen-turbo)                        │ │
│  │  (结构化解析)    │  │ • Pydantic 模型: IntentResult(intent, slots, conf)   │ │
│  │                 │  │ • ChatPromptTemplate + few-shot 示例                  │ │
│  │                 │  │ • 正则提取 JSON + 模型校验                            │ │
│  └────────┬────────┘  └──────────────────────────────────────────────────────┘ │
│           ↓                                                                     │
│  ┌─────────────────┐  输出: {"intent": ["rag_qa"], "slots": {...}, "conf": 0.88}│
│  │    路由层       │  根据 intent 数组决定走哪些链路(支持多意图)              │
│  │   (Router)      │  低置信度 (<0.65) 可兜底到 general 或提示用户换问法        │
│  └────────┬────────┘                                                            │
│           ↓                                                                     │
│  ┌──────────────┬──────────────┬──────────────┬──────────────┐                 │
│  │   RAG 能力   │  Agent 能力  │  Vision 能力 │  General 能力│                 │
│  │   (第5章)    │   (第7章)    │   (第8章)    │   (第3章)    │                 │
│  ├──────────────┼──────────────┼──────────────┼──────────────┤                 │
│  │ PyMuPDFLoader│ DataFrame    │ qwen-vl-max  │ ChatTongyi   │                 │
│  │ TextSplitter │ 元信息提取   │ 多模态消息   │ 带历史对话   │                 │
│  │ Chroma向量库 │ 代码生成     │ JSON结构化   │ 窗口裁剪     │                 │
│  │ DashScope    │ exec()执行   │ 实体提取     │ 上下文保持   │                 │
│  │ Embeddings   │ stdout捕获   │ 答案生成     │              │                 │
│  └──────────────┴──────────────┴──────────────┴──────────────┘                 │
│           ↓                                                                     │
│  ┌─────────────────┐                                                            │
│  │   响应组装层     │  合并多个能力的输出 + 意图徽章 + 置信度 + 引用来源         │
│  │  (Response)     │  st.markdown() 渲染 + st.image() 显示图表                  │
│  └─────────────────┘                                                            │
│                                                                                 │
└─────────────────────────────────────────────────────────────────────────────────┘
📥 输入层设计
st.chat_input() 获取用户文本输入
st.file_uploader() 支持 CSV/PDF/图片
st.session_state 缓存已处理文件
• 文件指针 seek(0) 避免重复读取问题
📤 输出层设计
• 意图徽章:📄 RAG / 📊 数据分析 / 🖼️ Vision / 💬 普通
• 置信度百分比显示
• Markdown 格式化回答
• 代码块 + 图表 + 引用来源
🤔 为什么采用管道式架构?
1. 关注点分离:意图识别、能力执行、响应组装各司其职,互不干扰
2. 可测试性:每个模块可以单独写单元测试,不依赖其他模块
3. 可替换性:想换 LLM?只改 Config 里的 model 名;想换向量库?只改 rag_answer 函数
4. 可扩展性:新增"邮件撰写"能力?加个 intent + 处理函数即可,不影响现有代码
5. 可观测性:每一步的输入输出都可以打日志,出错能精确定位

🧩 能力1:意图识别(Intent Recognition)

核心思想:让 LLM 先输出"可执行的 JSON 合同",而不是直接自由回答。
为什么重要:如果让 LLM 直接回答,你不知道它"想做什么",出错了也不知道是哪一步错了。 结构化后,你可以:
• 知道模型识别出了什么意图
• 知道置信度是多少(低于阈值可以兜底)
• 知道槽位是否完整(缺失可以追问)
📊 输出示例
{
  "intent": ["rag_qa"],
  "slots": {
    "query": "病假最多几天",
    "file_path": "?",
    "image_path": "?"
  },
  "confidence": 0.88
}
📝 Step 1: Pydantic 模型定义(类型安全)
python
# 意图枚举:只允许这4种意图,其他值会校验失败
IntentName = Literal["rag_qa", "data_agent", "vision_extract", "general"]

class IntentSlots(BaseModel):
    """槽位:从用户输入中提取的关键信息"""
    query: Optional[str] = None       # 用户的原始问题
    file_path: Optional[str] = None   # CSV 文件路径(如果提到)
    image_path: Optional[str] = None  # 图片路径(如果提到)

class IntentResult(BaseModel):
    """意图识别结果:intent + slots + confidence"""
    intent: List[IntentName] = Field(default_factory=list)  # 支持多意图
    slots: IntentSlots = Field(default_factory=IntentSlots)
    confidence: float = Field(ge=0.0, le=1.0)  # 置信度约束在 0~1
为什么用 Pydantic?
Literal 约束枚举值,LLM 输出 "rag" 会校验失败
Field(ge=0.0, le=1.0) 约束数值范围
model_validate() 自动校验 + 类型转换
📝 Step 2: Prompt 模板设计(few-shot 示例)
python
INTENT_PROMPT = ChatPromptTemplate.from_messages([
    ("system", 
     "你是企业智能助手【意图识别器】。你必须只输出严格 JSON(不要 Markdown,不要解释)。\n"
     "要求:\n"
     "- 支持多意图(intent 是数组)。\n"
     "- intent 只能取:rag_qa / data_agent / vision_extract / general。\n"
     "- slots 至少包含:query / file_path / image_path。缺失填 '?'。\n"
     "- confidence 为 0~1 的小数。\n"
     "\n"
     "意图判断规则:\n"
     "- 如果用户提到【数据分析】【统计】【画图】【CSV】【表格】等,用 data_agent\n"
     "- 如果用户提到【图片】【发票】【截图】【识别】【照片】等,用 vision_extract\n"
     "- 如果用户提到【文档】【PDF】【手册】【规定】【制度】等,用 rag_qa\n"
     "- 其他情况用 general\n"),
    
    # few-shot 示例1:数据分析
    ("human", "用户输入:用条形图展示各类别的数量分布\n只输出 JSON:\n"
              '{{"intent":["data_agent"],"slots":{{"query":"用条形图展示各类别的数量分布","file_path":"?","image_path":"?"}},"confidence":0.86}}'),
    
    # few-shot 示例2:图片识别
    ("human", "用户输入:这张发票截图里金额是多少?\n只输出 JSON:\n"
              '{{"intent":["vision_extract"],"slots":{{"query":"金额是多少","file_path":"?","image_path":"?"}},"confidence":0.82}}'),
    
    # few-shot 示例3:RAG 问答
    ("human", "用户输入:员工手册里病假最多几天?\n只输出 JSON:\n"
              '{{"intent":["rag_qa"],"slots":{{"query":"病假最多几天","file_path":"?","image_path":"?"}},"confidence":0.88}}'),
    
    # 实际输入
    ("human", "用户输入:{input}\n只输出 JSON:"),
])
Prompt 设计要点:
强约束:system 里明确"只输出 JSON,不要解释"
枚举列表:明确告诉模型只能输出这4种 intent
判断规则:给出关键词 → intent 的映射规则
few-shot 示例:3个示例覆盖常见场景,让模型学会格式
📝 Step 3: JSON 提取与校验(容错处理)
python
def extract_json(text: str) -> str:
    """从 LLM 输出中提取 JSON(容错处理)"""
    t = (text or "").strip()
    # 移除可能的 ```json 包裹
    t = re.sub(r"^```(?:json)?\s*", "", t, flags=re.IGNORECASE)
    t = re.sub(r"\s*```$", "", t)
    # 用正则提取 JSON 对象
    m = re.search(r"\{.*\}", t, flags=re.S)
    return m.group(0) if m else t

def recognize_intent(user_input: str) -> IntentResult:
    """识别用户意图"""
    llm = ChatTongyi(model=Config.INTENT_MODEL, temperature=0)  # qwen-turbo
    resp = (INTENT_PROMPT | llm).invoke({"input": user_input})
    raw = json.loads(extract_json(resp.content))
    return IntentResult.model_validate(raw)  # Pydantic 校验
为什么需要 extract_json?
LLM 有时会输出 ```json {...} ``` 格式,需要正则提取纯 JSON。
为什么用 qwen-turbo?
意图识别是"分类任务",不需要强推理能力,用小模型速度快、成本低。
💡 面试话术
"我用 Pydantic 定义结构化合同,LLM 输出 JSON 后自动校验类型和范围。Prompt 里用 few-shot 示例让模型学会格式,用正则容错处理 markdown 包裹。用 qwen-turbo 做意图识别,速度快成本低,P99 延迟 <500ms。"
⚠️ 常见踩坑
1. JSON 解析失败:LLM 可能输出不合法 JSON(如单引号、尾逗号),需要 try-catch + 兜底
2. 意图识别错误:用户说"帮我分析这个文档",可能被识别成 data_agent 而不是 rag_qa,需要优化 Prompt 里的关键词规则
3. 多意图冲突:用户说"分析 PDF 里的数据并画图",可能同时触发 rag_qa 和 data_agent,需要设计优先级

🧩 能力2:RAG 检索增强生成(Retrieval-Augmented Generation)

核心思想:LLM 不知道你的私有文档内容,直接问会"编造"。RAG 让答案"有出处、可验证"。
技术流程:
1. 加载:PyMuPDFLoader 读取 PDF
2. 切分:RecursiveCharacterTextSplitter 切成 chunks
3. 向量化:DashScopeEmbeddings 生成向量
4. 存储:Chroma 向量数据库
5. 检索:相似度搜索 top-k
6. 生成:LLM 基于上下文回答
📊 RAG 流程图
PDF
 ↓ PyMuPDFLoader
Documents
 ↓ TextSplitter
Chunks (800字/块)
 ↓ Embeddings
Vectors
 ↓ Chroma
VectorStore
 ↓ Retriever(k=4)
相关文档
 ↓ LLM
带引用的答案
📝 Step 1: PDF 加载与切分
python
def process_pdf(pdf_bytes: bytes) -> List[Any]:
    """PDF → 切分后的 chunks"""
    # 写入临时文件(PyMuPDFLoader 需要文件路径)
    with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp:
        tmp.write(pdf_bytes)
        tmp_path = tmp.name
    
    try:
        # 加载 PDF
        docs = PyMuPDFLoader(tmp_path).load()
        
        # 创建文档分块器
        splitter = RecursiveCharacterTextSplitter(
            chunk_size=Config.CHUNK_SIZE,        # 每块最大字符数
            chunk_overlap=Config.CHUNK_OVERLAP,  # 块间重叠字符数
            is_separator_regex=True,             # 使用正则表达式分隔符
            keep_separator=False,                # 不保留分隔符在chunk中
            separators=[
                "(?<=。)",   # 在句号之后切分(句号留在当前 chunk 结尾)
                "(?<=!)",   # 在感叹号之后切分
                "(?<=?)",   # 在问号之后切分
                "(?<=,)",   # 在逗号之后切分(次优先级)
                " ",         # 空格(最低优先级)
            ],
        )
        
        # 执行文档分块
        return splitter.split_documents(docs)
    finally:
        os.unlink(tmp_path)  # 清理临时文件
参数选择依据:
chunk_size=800:太小会丢失上下文,太大会引入噪音。800 字符约 400 个中文字,适合大多数场景
chunk_overlap=120:15% 重叠率,防止关键信息被切断
separators:按优先级切分,优先在段落/句子边界切分,保持语义完整
📝 Step 2: 向量检索与答案生成
python
def rag_answer(question: str, chunks: List[Any]) -> Dict[str, Any]:
    """RAG 问答:向量检索 + LLM 生成"""
    # 1. 创建向量存储
    embeddings = DashScopeEmbeddings(model=Config.EMBEDDING_MODEL)  # text-embedding-v2
    store = Chroma.from_documents(chunks, embeddings)
    
    # 2. 创建检索器
    retriever = store.as_retriever(search_kwargs={"k": Config.RETRIEVAL_K})  # k=4
    results = retriever.invoke(question)
    
    # 3. 拼接上下文
    context = "\n\n".join([doc.page_content for doc in results])
    
    # 4. 提取引用来源
    citations = [
        {"page": doc.metadata.get("page", "?"), "content": doc.page_content[:150] + "..."}
        for doc in results
    ]
    
    # 5. LLM 生成答案
    llm = ChatTongyi(model=Config.LLM_MODEL, temperature=0)
    prompt = f"""根据以下文档内容回答问题。

文档内容:
{context}

问题:{question}

要求:
1. 只根据文档内容回答,不要编造
2. 如果文档中没有相关信息,请说"文档中未找到相关信息"
3. 在答案中标注来源页码
"""
    resp = llm.invoke(prompt)
    answer = resp.content if isinstance(resp.content, str) else resp.content[0].get("text", "")
    
    return {"answer": answer, "citations": citations}
关键设计点:
k=4:检索 4 个最相关的 chunk,平衡召回率和精度
citations:返回引用来源,让答案可追溯
temperature=0:生成任务用低温度,减少"创造性"
💡 面试话术
"RAG 的核心价值不是'能回答',而是'答案能追溯到文档第几页'。我用 chunk_size=800 控制粒度,overlap=120 防止信息断裂。检索 top-k=4 平衡召回率和精度。每个答案都带 citations,用户可以点击查看原文。"
⚠️ 常见踩坑
1. 检索不到相关内容:chunk_size 太大导致向量"稀释",或者 embedding 模型不适合中文
2. 答案不准确:检索到的 chunk 不是最相关的,需要调整 k 值或使用 rerank
3. 上下文超长:k 太大导致 context 超过 LLM 上下文窗口,需要限制或摘要

🧩 能力3:数据分析 Agent(Code Generation + Execution)

核心思想:LLM 不知道你的数据长什么样,可能会编造。让 LLM 生成代码,代码执行是确定性的,结果可验证。
技术流程:
1. 元信息提取:shape, columns, dtypes, sample
2. 代码生成:LLM 根据元信息生成 Pandas 代码
3. 代码清洗:移除 markdown 包裹
4. 安全执行:exec() + stdout 重定向
5. 结果返回:执行输出 + 生成的代码 + 图表路径
📊 Agent 流程图
CSV
 ↓ pd.read_csv()
DataFrame
 ↓ 元信息提取
{shape, columns, sample}
 ↓ LLM (qwen-max)
Pandas 代码
 ↓ exec()
执行结果 + chart.png
📝 Step 1: DataFrame 元信息提取
python
def agent_answer(df: pd.DataFrame, question: str) -> Dict[str, Any]:
    """数据分析 Agent:自然语言 → Pandas 代码 → 执行结果"""
    import sys
    import numpy as np
    
    # 提取 DataFrame 元信息(不传原始数据,只传结构信息)
    df_meta = {
        "shape": df.shape,                              # (行数, 列数)
        "columns": list(df.columns),                    # 列名列表
        "dtypes": df.dtypes.astype(str).to_dict(),      # 每列的数据类型
        "sample": df.sample(n=min(5, len(df)), random_state=42).to_string(index=False),  # 5行样本
    }
为什么只传元信息?
• 原始数据可能很大,传给 LLM 会超 token 限制
• 元信息足够让 LLM 理解数据结构并生成正确代码
• sample 提供 5 行样本,让 LLM 知道数据长什么样
📝 Step 2: 代码生成与安全执行
python
    # LLM 生成代码
    llm = ChatTongyi(model=Config.LLM_MODEL, temperature=0)
    prompt = f"""你是数据分析专家。根据以下 DataFrame 元信息和用户问题,生成 Pandas 代码。

DataFrame 元信息:
- shape: {df_meta['shape']}
- columns: {df_meta['columns']}
- dtypes: {df_meta['dtypes']}
- sample:
{df_meta['sample']}

用户问题:{question}

要求:
1. 只输出 Python 代码,不要解释
2. 代码中用 df 变量代表 DataFrame
3. 最后用 print() 输出结果
4. 如果需要画图,用 matplotlib 并保存到 chart.png
"""
    resp = llm.invoke(prompt)
    
    # 清洗代码(移除 markdown 包裹)
    code = resp.content.strip()
    code = re.sub(r"^```(?:python)?\s*", "", code, flags=re.IGNORECASE)
    code = re.sub(r"\s*```$", "", code)
    
    # 执行代码,重定向 stdout 捕获 print 输出
    old_stdout = sys.stdout
    sys.stdout = io.StringIO()
    
    try:
        exec_globals = {"df": df, "pd": pd, "np": np}
        
        # 动态导入 matplotlib(如果代码中用到)
        if "plt" in code or "matplotlib" in code:
            try:
                import matplotlib
                matplotlib.use("Agg")  # 无头模式
                import matplotlib.pyplot as plt
                exec_globals["plt"] = plt
                exec_globals["matplotlib"] = matplotlib
            except ImportError:
                # matplotlib 未安装,移除绘图代码
                code = re.sub(r".*plt\..*\n?", "", code)
                print("注意:matplotlib 未安装,已跳过绘图代码\n")
        
        exec(code, exec_globals)
        output = sys.stdout.getvalue()
    except Exception as e:
        output = f"执行出错: {e}"
    finally:
        sys.stdout = old_stdout
    
    chart_path = "chart.png" if os.path.exists("chart.png") else None
    return {"answer": output, "code": code, "chart": chart_path}
关键设计点:
stdout 重定向:捕获 print() 输出作为结果
matplotlib.use("Agg"):无头模式,不弹窗
动态导入:只有代码中用到 plt 才导入,避免不必要的依赖
🔍 深度解析:为什么必须重定向 stdout?
问题场景: AI 生成的 Python 代码通常包含 print() 语句来输出分析结果。如果不重定向输出,这些内容会直接打印到服务器控制台,而不是返回给用户。
不重定向会有什么问题?
❌ 错误示例(不重定向):
# AI 生成的代码
print("数据统计:")
print(f"总行数:{len(df)}")
print(f"平均值:{df['price'].mean()}")

# 问题:这些输出会直接显示在服务器终端,用户看不到!
# 返回给用户的 answer 只会是空字符串 ""
重定向的工作原理:
✅ 正确做法(重定向):
old_stdout = sys.stdout # 1. 保存原始输出(终端)
sys.stdout = io.StringIO() # 2. 重定向到内存缓冲区

exec(code, exec_globals) # 3. 执行代码,print 输出到内存
output = sys.stdout.getvalue() # 4. 获取捕获的内容

sys.stdout = old_stdout # 5. 恢复原始输出
return {"answer": output, ...} # 6. 将捕获的内容返回给用户
实际效果对比:
❌ 不重定向
用户看到:
""(空字符串)

服务器终端:
数据统计:
总行数:1000
平均值:25.6
✅ 重定向后
用户看到:
"数据统计:
总行数:1000
平均值:25.6"


服务器终端:
(无额外输出)
为什么必须用 finally 恢复?
如果代码执行出错,sys.stdout 仍然指向 StringIO,会导致后续所有 print() 都不显示在终端,造成调试困难。
⚠️ 安全警告:exec() 风险
风险:exec() 可以执行任意 Python 代码,恶意用户可能注入危险代码(如 os.system("rm -rf /")
生产环境建议:
沙箱执行:用 Docker 容器或 RestrictedPython 限制可执行的代码
HITL 审批:生成代码后先展示给用户,用户确认后再执行
白名单:只允许特定的库和函数(如只允许 pandas、numpy)
💡 面试话术
"Agent 的核心是'让 LLM 生成代码而不是直接回答',因为代码执行是确定性的,结果可验证。我只传 DataFrame 元信息给 LLM,避免数据泄露和 token 超限。生产环境会加 HITL 审批,用户确认代码后再执行。"

🧩 能力4:Vision 图片识别(Multi-Modal Extraction)

核心思想:用 Vision 模型(qwen-vl-max)识别图片内容,输出结构化 JSON,方便下游处理。
典型场景:
• 发票识别:提取金额、日期、开票方
• 表格截图:提取表格数据
• 证件识别:提取姓名、身份证号
• 图表分析:描述图表内容和趋势
📊 输出示例
{
  "summary": "这是一张增值税发票",
  "entities": [
    "金额: ¥1,234.56",
    "日期: 2024-01-15",
    "开票方: XX公司"
  ],
  "answer": "发票金额是1234.56元"
}
📝 Vision 识别完整代码
python
def vision_answer(image_path: str, question: str) -> Dict[str, Any]:
    """Vision 图片识别:图片 → 结构化 JSON → 答案"""
    # 确保路径格式正确(qwen-vl 需要 file:// 前缀)
    if not image_path.startswith("file://"):
        image_path = f"file://{os.path.abspath(image_path)}"
    
    # 使用 Vision 模型
    vision_llm = ChatTongyi(model=Config.VISION_MODEL, temperature=0)  # qwen-vl-max
    
    # Prompt 设计:要求输出结构化 JSON
    prompt = (
        f"你是识图助手。请根据图片内容回答:{question}\n"
        '请严格输出 JSON:{"summary": "图片内容概述", "entities": ["识别出的实体"], "answer": "针对问题的答案"}'
    )
    
    # 构造多模态消息:文字 + 图片
    msg = HumanMessage(content=[
        {"text": prompt},
        {"image": image_path},  # 图片路径
    ])
    
    resp = vision_llm.invoke([msg])
    
    # 解析响应(可能是 list 或 str)
    text = resp.content[0]["text"] if isinstance(resp.content, list) else resp.content
    
    # 提取 JSON 并解析
    try:
        obj = json.loads(extract_json(text))
        return {"answer": obj.get("answer", obj.get("summary", "")), "contract": obj}
    except json.JSONDecodeError:
        # JSON 解析失败,返回原始文本
        return {"answer": text, "contract": {"raw": text}}
关键设计点:
file:// 前缀:qwen-vl 模型要求本地文件用 file:// 协议
多模态消息:HumanMessage 的 content 是数组,包含 text 和 image
两阶段设计:阶段1(识图)出错是"没看懂图",阶段2(JSON解析)出错是"格式问题"
💡 面试话术
"我用两阶段设计:阶段1(识图)出错是'没看懂图',阶段2(JSON解析)出错是'格式问题'。分开后,出错能精确定位。输出结构化 JSON 而不是自由文本,方便下游系统处理(如入库、生成报告)。"

🧩 能力5:多轮对话与记忆管理(Memory Management)

核心思想:用 Streamlit 的 session_state 管理聊天历史,每次请求带上最近 N 轮对话,让模型"记住"上下文。
为什么重要:
• 用户问"年假怎么算?",再追问"那我入职不满一年呢?"——模型必须记住上一轮的"年假"上下文
• 企业场景下 A 用户聊报销、B 用户聊年假——如果没有会话隔离,A 看到 B 的对话内容就是生产事故
📊 窗口裁剪策略
对话历史: [1,2,3,...,15]
窗口大小: 10
注入模型: [6,7,8,9,10,11,12,13,14,15]

好处:
• 控制 Token 预算
• 保留最近上下文
• 避免"记住太早的废话"
📝 多轮对话完整代码
python
def general_answer(question: str, chat_history: List[Dict]) -> str:
    """普通问答:带上下文的多轮对话"""
    llm = ChatTongyi(model=Config.LLM_MODEL, temperature=0.7)
    
    # 构造带历史的消息列表(只取最近10轮,控制 Token 预算)
    messages = []
    for msg in chat_history[-10:]:  # 窗口裁剪:只保留最近10轮
        if msg["role"] == "user":
            messages.append(HumanMessage(content=msg["content"]))
        else:
            messages.append(AIMessage(content=msg["content"]))
    messages.append(HumanMessage(content=question))
    
    resp = llm.invoke(messages)
    return resp.content if isinstance(resp.content, str) else resp.content[0].get("text", "")

# ═══════════════════════════════════════════════════════════════════════════
# Streamlit 会话状态管理
# ═══════════════════════════════════════════════════════════════════════════

def init_session_state():
    """初始化 session state"""
    if "messages" not in st.session_state:
        st.session_state.messages = []  # 聊天历史
    if "uploaded_files" not in st.session_state:
        st.session_state.uploaded_files = {
            "csv_name": None, "csv_df": None,
            "pdf_name": None, "pdf_chunks": None,
            "image_name": None, "image_path": None, "image_bytes": None,
        }

# 保存对话
st.session_state.messages.append({"role": "user", "content": prompt})
st.session_state.messages.append({"role": "assistant", "content": response, "chart": chart_path})
关键设计点:
chat_history[-10:]:窗口裁剪,只保留最近10轮,控制 Token 预算
session_state:Streamlit 的会话状态,每个用户独立
role: user/assistant:标准的对话格式,方便构造 LLM 消息
💡 面试话术
"我用 session_state 做会话隔离,每个用户独立的聊天历史。窗口裁剪只注入最近 N 轮,避免 Token 超限。生产环境可以换成 Redis,支持多实例部署和持久化。"

⚠️ 工程踩坑与最佳实践

🐛 踩坑1:file_uploader 文件指针问题
问题:Streamlit 的 file_uploader 在读取后文件指针会移到末尾,再次读取会得到空内容。
解决:每次读取前调用 file.seek(0) 重置指针,并用 session_state 缓存已处理的文件。
python
csv_file = st.file_uploader("📊 CSV", type=["csv"])
if csv_file is not None:
    csv_name = csv_file.name
    # 检查是否是新文件(避免重复处理)
    if st.session_state.uploaded_files.get("csv_name") != csv_name:
        csv_file.seek(0)  # 重置文件指针!
        df = pd.read_csv(csv_file)
        st.session_state.uploaded_files["csv_name"] = csv_name
        st.session_state.uploaded_files["csv_df"] = df
🐛 踩坑2:图表文件残留
问题:上一轮数据分析生成的 chart.png 会在下一轮 RAG 问答时被错误显示。
解决:每次处理新消息前清理旧图表,只有数据分析响应才显示图表。
python
# 处理新消息前清理旧图表
if os.path.exists("chart.png"):
    os.remove("chart.png")

# 只有数据分析时才显示图表
if os.path.exists("chart.png") and "数据分析" in response:
    st.image("chart.png")
🐛 踩坑3:LLM 输出格式不稳定
问题:LLM 有时输出 ```json {...} ```,有时输出纯 JSON,有时还加解释文字。
解决:用正则提取 JSON,加 try-catch 兜底。
python
def extract_json(text: str) -> str:
    """从 LLM 输出中提取 JSON(容错处理)"""
    t = (text or "").strip()
    t = re.sub(r"^```(?:json)?\s*", "", t, flags=re.IGNORECASE)
    t = re.sub(r"\s*```$", "", t)
    m = re.search(r"\{.*\}", t, flags=re.S)
    return m.group(0) if m else t

# 使用时加 try-catch
try:
    obj = json.loads(extract_json(text))
except json.JSONDecodeError:
    obj = {"raw": text}  # 兜底
🐛 踩坑4:matplotlib 未安装
问题:用户环境没装 matplotlib,LLM 生成的绘图代码会报错。
解决:动态检测,未安装时移除绘图代码并提示用户。
python
if "plt" in code or "matplotlib" in code:
    try:
        import matplotlib
        matplotlib.use("Agg")
        import matplotlib.pyplot as plt
        exec_globals["plt"] = plt
    except ImportError:
        # matplotlib 未安装,移除绘图代码
        code = re.sub(r".*plt\..*\n?", "", code)
        print("注意:matplotlib 未安装,已跳过绘图代码\n")

📦 完整代码获取

完整可运行代码约 665 行
代码位置:chapter_12/chatbot.py

代码包含:
• 配置类(Config):统一管理模型名、参数
• 意图识别(Pydantic + ChatPromptTemplate + few-shot)
• RAG 检索(PyMuPDF + RecursiveCharacterTextSplitter + Chroma + DashScopeEmbeddings)
• 数据分析 Agent(DataFrame 元信息 + 代码生成 + exec 执行)
• Vision 图片识别(qwen-vl-max + 多模态消息)
• 多轮对话(session_state + 窗口裁剪)
• Streamlit UI(侧边栏配置 + 文件上传 + 聊天界面)
• 路由器(process_user_message):根据意图分发到不同能力
📋 requirements.txt
text
streamlit>=1.28.0
langchain>=0.1.0
langchain-community>=0.0.10
dashscope>=1.14.0
chromadb>=0.4.0
pymupdf>=1.23.0
pandas>=2.0.0
pydantic>=2.0.0
matplotlib>=3.7.0

📝 完整源码 (chatbot.py)

完整代码,可直接复制使用:

📄 chatbot.py (665行)
python
"""
chatbot.py — Enterprise AI Copilot 聊天机器人

功能:
1. 连续多轮对话(聊天历史管理)
2. 意图识别(自动判断用户想做什么)
3. 文件上传(CSV/PDF/图片)并自动关联到对话
4. 能力路由(RAG/Agent/Vision/普通问答)
5. 全链路 Trace

启动方式:
    streamlit run chatbot.py
"""

import io
import json
import os
import re
import tempfile
from datetime import datetime
from typing import Any, Dict, List, Literal, Optional

import pandas as pd
import streamlit as st

from langchain_community.chat_models import ChatTongyi
from langchain_community.document_loaders import PyMuPDFLoader
from langchain_community.embeddings import DashScopeEmbeddings
from langchain_community.vectorstores import Chroma
from langchain_core.messages import AIMessage, HumanMessage
from langchain_core.prompts import ChatPromptTemplate
from langchain_text_splitters import RecursiveCharacterTextSplitter
from pydantic import BaseModel, Field, ValidationError


# ═══════════════════════════════════════════════════════════════════════════
# 配置
# ═══════════════════════════════════════════════════════════════════════════

class Config:

    # 主要对话模型 - 用于RAG问答、数据分析等需要高质量输出的任务
    LLM_MODEL = "qwen-max"
    
    # 意图识别模型 - 使用轻量级模型降低成本,提高响应速度
    INTENT_MODEL = "qwen-turbo"
    
    # 视觉识别模型 - 支持图片理解和OCR
    VISION_MODEL = "qwen-vl-max"
    
    # 向量嵌入模型 - 用于文档语义检索
    EMBEDDING_MODEL = "text-embedding-v2"
    
    # 意图识别置信度阈值 - 低于此值的识别结果可能需要人工确认
    CONFIDENCE_THRESHOLD = 0.65
    
    # 文档分块配置 - 影响检索精度和token成本
    CHUNK_SIZE = 800        # 每个分块的最大字符数
    CHUNK_OVERLAP = 120     # 分块间的重叠字符数,避免语义断裂
    
    # 检索配置 - 控制返回的相关文档数量
    RETRIEVAL_K = 4         # 检索返回的最相关文档数量


# ═══════════════════════════════════════════════════════════════════════════
# 意图识别
# ═══════════════════════════════════════════════════════════════════════════

# 支持的意图类型 - 使用Literal类型确保类型安全
# rag_qa: 文档检索问答
# data_agent: 数据分析代理
# vision_extract: 图片识别提取
# general: 普通对话问答
IntentName = Literal["rag_qa", "data_agent", "vision_extract", "general"]


class IntentSlots(BaseModel):
    """
    意图槽位类 - 存储从用户输入中提取的具体信息
    
    属性说明:
    - query: 用户的核心查询内容
    - file_path: 相关文件路径(如果有)
    - image_path: 相关图片路径(如果有)
    """
    query: Optional[str] = None           # 用户的核心问题或请求
    file_path: Optional[str] = None        # 关联的文件路径
    image_path: Optional[str] = None       # 关联的图片路径


class IntentResult(BaseModel):
    """
    意图识别结果类 - 存储完整的意图识别信息
    
    属性说明:
    - intent: 识别出的意图列表(支持多意图)
    - slots: 提取的槽位信息
    - confidence: 识别置信度(0.0-1.0)
    """
    intent: List[IntentName] = Field(default_factory=list)  # 识别出的意图列表
    slots: IntentSlots = Field(default_factory=IntentSlots)  # 提取的槽位
    confidence: float = Field(ge=0.0, le=1.0)              # 置信度,限制在0-1之间


INTENT_PROMPT = ChatPromptTemplate.from_messages([
    ("system", 
     "你是企业智能助手【意图识别器】。你必须只输出严格 JSON(不要 Markdown,不要解释)。\n"
     "要求:\n"
     "- 支持多意图(intent 是数组)。\n"
     "- intent 只能取:rag_qa / data_agent / vision_extract / general。\n"
     "- slots 至少包含:query / file_path / image_path。缺失填 '?'。\n"
     "- confidence 为 0~1 的小数。\n"
     "\n"
     "意图判断规则:\n"
     "- 如果用户提到【数据分析】【统计】【画图】【CSV】【表格】【分析数据】等,用 data_agent\n"
     "- 如果用户提到【图片】【发票】【截图】【识别】【照片】【看图】等,用 vision_extract\n"
     "- 如果用户提到【文档】【PDF】【手册】【规定】【制度】【知识库】【查询文档】等,用 rag_qa\n"
     "- 其他情况用 general\n"),
    
    ("human", 
     "用户输入:用条形图展示各类别的数量分布\n"
     "只输出 JSON:\n"
     '{{"intent":["data_agent"],"slots":{{"query":"用条形图展示各类别的数量分布","file_path":"?","image_path":"?"}},"confidence":0.86}}'),
    
    ("human", 
     "用户输入:这张发票截图里金额是多少?\n"
     "只输出 JSON:\n"
     '{{"intent":["vision_extract"],"slots":{{"query":"金额是多少","file_path":"?","image_path":"?"}},"confidence":0.82}}'),
    
    ("human", 
     "用户输入:员工手册里病假最多几天?\n"
     "只输出 JSON:\n"
     '{{"intent":["rag_qa"],"slots":{{"query":"病假最多几天","file_path":"?","image_path":"?"}},"confidence":0.88}}'),
    
    ("human", "用户输入:{input}\n只输出 JSON:"),
])


def extract_json(text: str) -> str:

    t = (text or "").strip()  # 去除首尾空白
    
    # 移除开头的Markdown代码块标记
    t = re.sub(r"^```(?:json)?\s*", "", t, flags=re.IGNORECASE)
    
    # 移除结尾的Markdown代码块标记
    t = re.sub(r"\s*```$", "", t)
    
    # 提取第一个完整的JSON对象
    m = re.search(r"\{.*\}", t, flags=re.S)
    return m.group(0) if m else t


def recognize_intent(user_input: str) -> IntentResult:

    # 使用轻量级模型进行意图识别,降低成本
    llm = ChatTongyi(model=Config.INTENT_MODEL, temperature=0)
    
    # 调用LLM进行意图识别
    resp = (INTENT_PROMPT | llm).invoke({"input": user_input})
    
    # 提取并解析JSON结果
    raw = json.loads(extract_json(resp.content))
    
    # 验证并返回结构化结果
    return IntentResult.model_validate(raw)


# ═══════════════════════════════════════════════════════════════════════════
# RAG 能力
# ═══════════════════════════════════════════════════════════════════════════

def process_pdf(pdf_bytes: bytes) -> List[Any]:

    # 创建临时文件保存PDF内容
    with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp:
        tmp.write(pdf_bytes)
        tmp_path = tmp.name
    
    try:
        # 使用PyMuPDF加载PDF文档
        docs = PyMuPDFLoader(tmp_path).load()
        
        # 创建文档分块器
        splitter = RecursiveCharacterTextSplitter(
            chunk_size=Config.CHUNK_SIZE,        # 每块最大字符数
            chunk_overlap=Config.CHUNK_OVERLAP,  # 块间重叠字符数
            is_separator_regex=True,             # 使用正则表达式分隔符
            keep_separator=False,                # 不保留分隔符在chunk中
            separators=[
                "(?<=。)",   # 在句号之后切分(句号留在当前 chunk 结尾)
                "(?<=!)",   # 在感叹号之后切分
                "(?<=?)",   # 在问号之后切分
                "(?<=,)",   # 在逗号之后切分(次优先级)
                " ",         # 空格(最低优先级)
            ],
        )
        
        # 执行文档分块
        return splitter.split_documents(docs)
        
    finally:
        # 清理临时文件
        os.unlink(tmp_path)


def rag_answer(question: str, chunks: List[Any]) -> Dict[str, Any]:

    # 初始化向量嵌入模型
    embeddings = DashScopeEmbeddings(model=Config.EMBEDDING_MODEL)
    
    # 创建向量数据库并添加文档
    # 注意:这里每次都重新创建,生产环境应该使用持久化存储
    store = Chroma.from_documents(chunks, embeddings)
    
    # 创建检索器,返回最相关的K个文档块
    retriever = store.as_retriever(search_kwargs={"k": Config.RETRIEVAL_K})
    
    # 检索相关文档
    results = retriever.invoke(question)
    
    # 构造上下文文本
    context_parts = []
    for i, doc in enumerate(results):
        page_num = doc.metadata.get("page", doc.metadata.get("page_number", "未知"))
        context_parts.append(f"【第 {page_num} 页】\n{doc.page_content}")
    context = "\n\n".join(context_parts)
    
    # 构造引用信息(页码和内容片段)
    citations = [
        {
            "page": doc.metadata.get("page", doc.metadata.get("page_number", "?")),  # 文档页码(支持多种metadata key)
            "content": doc.page_content[:150] + "..."  # 内容片段(前150字符)
        }
        for doc in results
    ]
    
    # 构造引用信息字符串(用于LLM理解如何标注来源)
    citation_guide = "\n".join([f"- 第 {c['page']} 页: {c['content'][:100]}..." for c in citations])
    
    # 构造RAG问答prompt - 明确要求LLM在答案中标注具体页码
    llm = ChatTongyi(model=Config.LLM_MODEL, temperature=0)
    prompt = f"""根据以下文档内容回答问题。

文档内容(已标注页码):
{context}

问题:{question}

引用来源参考:
{citation_guide}

要求:
1. 只根据文档内容回答,不要编造
2. 如果文档中没有相关信息,请说"文档中未找到相关信息"
3. 在答案中标注来源页码,格式为"(来源页码:第X页)"或"(第X页)"
4. 对于每个关键信息,都要注明具体来源页码
"""
    
    # 调用LLM生成答案
    resp = llm.invoke(prompt)
    answer = resp.content if isinstance(resp.content, str) else resp.content[0].get("text", "")
    
    return {"answer": answer, "citations": citations}


# ═══════════════════════════════════════════════════════════════════════════
# 数据分析 Agent 能力
# ═══════════════════════════════════════════════════════════════════════════

def agent_answer(df: pd.DataFrame, question: str) -> Dict[str, Any]:
    """数据分析 Agent"""
    import sys
    import numpy as np
    
    df_meta = {
        "shape": df.shape,
        "columns": list(df.columns),
        "dtypes": df.dtypes.astype(str).to_dict(),
        "sample": df.sample(n=min(5, len(df)), random_state=42).to_string(index=False),
    }
    
    llm = ChatTongyi(model=Config.LLM_MODEL, temperature=0)
    prompt = f"""你是数据分析专家。根据以下 DataFrame 元信息和用户问题,生成 Pandas 代码。

DataFrame 元信息:
- shape: {df_meta['shape']}
- columns: {df_meta['columns']}
- dtypes: {df_meta['dtypes']}
- sample:
{df_meta['sample']}

用户问题:{question}

要求:
1. 只输出 Python 代码,不要解释
2. 代码中用 df 变量代表 DataFrame
3. 最后用 print() 输出结果
4. 如果需要画图,用 matplotlib 并保存到 chart.png
"""
    resp = llm.invoke(prompt)
    code = resp.content.strip()
    code = re.sub(r"^```(?:python)?\s*", "", code, flags=re.IGNORECASE)
    code = re.sub(r"\s*```$", "", code)
    
    # 执行代码
    old_stdout = sys.stdout
    sys.stdout = io.StringIO()
    
    try:
        exec_globals = {"df": df, "pd": pd, "np": np}
        
        # 尝试导入 matplotlib(如果代码中用到了)
        if "plt" in code or "matplotlib" in code:
            try:
                import matplotlib
                matplotlib.use("Agg")
                import matplotlib.pyplot as plt
                exec_globals["plt"] = plt
                exec_globals["matplotlib"] = matplotlib
            except ImportError:
                # matplotlib 未安装,从代码中移除绘图相关代码
                code = re.sub(r".*plt\..*\n?", "", code)
                code = re.sub(r".*matplotlib.*\n?", "", code)
                print("注意:matplotlib 未安装,已跳过绘图代码\n")
        
        exec(code, exec_globals)
        output = sys.stdout.getvalue()
    except Exception as e:
        output = f"执行出错: {e}"
    finally:
        sys.stdout = old_stdout
    
    chart_path = "chart.png" if os.path.exists("chart.png") else None
    return {"answer": output, "code": code, "chart": chart_path}


# ═══════════════════════════════════════════════════════════════════════════
# Vision 能力
# ═══════════════════════════════════════════════════════════════════════════

def vision_answer(image_path: str, question: str) -> Dict[str, Any]:
    """Vision 图片识别"""
    if not image_path.startswith("file://"):
        image_path = f"file://{os.path.abspath(image_path)}"
    
    vision_llm = ChatTongyi(model=Config.VISION_MODEL, temperature=0)
    prompt = (
        f"你是识图助手。请根据图片内容回答:{question}\n"
        '请严格输出 JSON:{{"summary": "图片内容概述", "entities": ["识别出的实体"], "answer": "针对问题的答案"}}'
    )
    
    msg = HumanMessage(content=[
        {"text": prompt},
        {"image": image_path},
    ])
    
    resp = vision_llm.invoke([msg])
    text = resp.content[0]["text"] if isinstance(resp.content, list) else resp.content
    
    try:
        obj = json.loads(extract_json(text))
        return {"answer": obj.get("answer", obj.get("summary", "")), "contract": obj}
    except json.JSONDecodeError:
        return {"answer": text, "contract": {"raw": text}}


# ═══════════════════════════════════════════════════════════════════════════
# 普通问答
# ═══════════════════════════════════════════════════════════════════════════

def general_answer(question: str, chat_history: List[Dict]) -> str:
    """普通问答(带上下文)"""
    llm = ChatTongyi(model=Config.LLM_MODEL, temperature=0.7)
    
    # 构造带历史的 prompt
    messages = []
    for msg in chat_history[-10:]:  # 只取最近10轮
        if msg["role"] == "user":
            messages.append(HumanMessage(content=msg["content"]))
        else:
            messages.append(AIMessage(content=msg["content"]))
    messages.append(HumanMessage(content=question))
    
    resp = llm.invoke(messages)
    return resp.content if isinstance(resp.content, str) else resp.content[0].get("text", "")


# ═══════════════════════════════════════════════════════════════════════════
# Streamlit 聊天界面
# ═══════════════════════════════════════════════════════════════════════════

def init_session_state():
    """初始化 session state"""
    if "messages" not in st.session_state:
        st.session_state.messages = []
    if "uploaded_files" not in st.session_state:
        st.session_state.uploaded_files = {
            "csv_name": None,
            "csv_df": None,
            "pdf_name": None,
            "pdf_chunks": None,
            "image_name": None,
            "image_path": None,
            "image_bytes": None,
        }


def render_sidebar():
    """渲染侧边栏 - 简化为只保留API Key和清空按钮"""
    with st.sidebar:
        st.header("⚙️ 配置")
        
        # API Key
        api_key = st.text_input(
            "DashScope API Key",
            type="password",
            value=os.getenv("DASHSCOPE_API_KEY", ""),
        )
        if api_key:
            os.environ["DASHSCOPE_API_KEY"] = api_key
        
        st.divider()
        
        # 已上传文件状态
        st.header("📁 已上传文件")
        files = st.session_state.uploaded_files
        
        has_files = False
        if files.get("csv_df") is not None:
            st.success(f"📊 CSV: {files.get('csv_name', 'unknown')}")
            has_files = True
        if files.get("pdf_chunks") is not None:
            st.success(f"📄 PDF: {files.get('pdf_name', 'unknown')}")
            has_files = True
        if files.get("image_path") is not None:
            st.success(f"🖼️ 图片: {files.get('image_name', 'unknown')}")
            has_files = True
        
        if not has_files:
            st.info("暂无文件,请在底部聊天区上传")
        
        st.divider()
        
        # 清空按钮
        col1, col2 = st.columns(2)
        with col1:
            if st.button("🗑️ 清空对话", use_container_width=True):
                st.session_state.messages = []
                st.rerun()
        with col2:
            if st.button("📤 清空文件", use_container_width=True):
                st.session_state.uploaded_files = {
                    "csv_name": None, "csv_df": None,
                    "pdf_name": None, "pdf_chunks": None,
                    "image_name": None, "image_path": None, "image_bytes": None,
                }
                st.rerun()


def get_intent_badge(intent: str) -> str:
    """获取意图徽章"""
    badges = {
        "rag_qa": "📄 RAG",
        "data_agent": "📊 数据分析",
        "vision_extract": "🖼️ Vision",
        "general": "💬 普通问答",
    }
    return badges.get(intent, intent)


def process_user_message(user_input: str) -> str:
    """处理用户消息,返回 AI 回复"""
    files = st.session_state.uploaded_files
    
    # 1. 意图识别
    try:
        intent_result = recognize_intent(user_input)
    except Exception as e:
        return f"❌ 意图识别失败: {e}"
    
    # 显示意图识别结果
    intent_str = ", ".join([get_intent_badge(i) for i in intent_result.intent])
    confidence_str = f"{intent_result.confidence:.0%}"
    
    response_parts = [f"🎯 **意图识别**: {intent_str} (置信度: {confidence_str})\n"]
    
    # 2. 根据意图路由
    for intent in intent_result.intent:
        if intent == "rag_qa":
            if files["pdf_chunks"] is None:
                response_parts.append("⚠️ **RAG 问答**需要先上传 PDF 文件(在左侧边栏上传)\n")
            else:
                with st.spinner("正在检索文档并生成答案..."):
                    result = rag_answer(user_input, files["pdf_chunks"])
                response_parts.append(f"📄 **RAG 回答**:\n{result['answer']}\n")
                if result["citations"]:
                    citations_str = "\n".join([f"  - 第 {c['page']} 页: {c['content'][:50]}..." for c in result["citations"][:3]])
                    response_parts.append(f"\n📚 **引用来源**:\n{citations_str}\n")
        
        elif intent == "data_agent":
            if files["csv_df"] is None:
                response_parts.append("⚠️ **数据分析**需要先上传 CSV 文件(在左侧边栏上传)\n")
            else:
                with st.spinner("正在生成并执行代码..."):
                    result = agent_answer(files["csv_df"], user_input)
                response_parts.append(f"📊 **数据分析结果**:\n```\n{result['answer']}\n```\n")
                response_parts.append(f"\n📝 **生成的代码**:\n```python\n{result['code']}\n```\n")
                if result["chart"]:
                    response_parts.append("\n📈 **图表已生成** (chart.png)\n")
        
        elif intent == "vision_extract":
            if files["image_path"] is None:
                response_parts.append("⚠️ **图片识别**需要先上传图片(在左侧边栏上传)\n")
            else:
                with st.spinner("正在识别图片..."):
                    result = vision_answer(files["image_path"], user_input)
                response_parts.append(f"🖼️ **图片识别结果**:\n{result['answer']}\n")
                if "contract" in result and result["contract"]:
                    response_parts.append(f"\n📋 **结构化数据**:\n```json\n{json.dumps(result['contract'], ensure_ascii=False, indent=2)}\n```\n")
        
        else:  # general
            with st.spinner("正在思考..."):
                answer = general_answer(user_input, st.session_state.messages)
            response_parts.append(f"💬 **回答**:\n{answer}\n")
    
    return "\n".join(response_parts)


def render_chat_input_area():
    """渲染聊天输入区域"""
    
    # 初始化
    for k in ["csv", "pdf", "img"]:
        if f"show_{k}" not in st.session_state:
            st.session_state[f"show_{k}"] = False
    
    # 显示已上传文件
    f = st.session_state.uploaded_files
    files = []
    if f.get("csv_df") is not None: files.append("📊CSV")
    if f.get("pdf_chunks") is not None: files.append("📄PDF") 
    if f.get("image_path") is not None: files.append("🖼️图片")
    
    if files:
        st.caption(f"已上传: {' | '.join(files)}")
    
    # 统一的输入区域容器
    with st.container(border=True):
        # 文件上传按钮行
        c1, c2, c3, c4 = st.columns(4)
        
        with c1:
            if st.button("📁 CSV", use_container_width=True, type="secondary"):
                st.session_state.show_csv = not st.session_state.get("show_csv", False)
                for k in ["pdf", "img"]: st.session_state[f"show_{k}"] = False
                st.rerun()
        
        with c2:
            if st.button("📄 PDF", use_container_width=True, type="secondary"):
                st.session_state.show_pdf = not st.session_state.get("show_pdf", False)
                for k in ["csv", "img"]: st.session_state[f"show_{k}"] = False
                st.rerun()
        
        with c3:
            if st.button("🖼️ 图片", use_container_width=True, type="secondary"):
                st.session_state.show_img = not st.session_state.get("show_img", False)
                for k in ["csv", "pdf"]: st.session_state[f"show_{k}"] = False
                st.rerun()
        
        with c4:
            if st.button("🗑️ 清空", use_container_width=True, type="secondary"):
                st.session_state.uploaded_files = {k: None for k in st.session_state.uploaded_files}
                for k in ["csv", "pdf", "img"]: st.session_state[f"show_{k}"] = False
                st.rerun()
        
        # 文件上传器(在按钮下方显示)
        if st.session_state.get("show_csv"):
            file = st.file_uploader("选择CSV文件", type=["csv"], key="up_csv")
            if file:
                st.session_state.uploaded_files["csv_df"] = pd.read_csv(file)
                st.session_state.uploaded_files["csv_name"] = file.name
                st.session_state.show_csv = False
                st.rerun()
        
        if st.session_state.get("show_pdf"):
            file = st.file_uploader("选择PDF文件", type=["pdf"], key="up_pdf")
            if file:
                with st.spinner("处理PDF..."):
                    st.session_state.uploaded_files["pdf_chunks"] = process_pdf(file.read())
                    st.session_state.uploaded_files["pdf_name"] = file.name
                st.session_state.show_pdf = False
                st.rerun()
        
        if st.session_state.get("show_img"):
            file = st.file_uploader("选择图片", type=["png","jpg","jpeg"], key="up_img")
            if file:
                img_bytes = file.read()
                with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as tmp:
                    tmp.write(img_bytes)
                    st.session_state.uploaded_files["image_path"] = tmp.name
                st.session_state.uploaded_files["image_name"] = file.name
                st.session_state.uploaded_files["image_bytes"] = img_bytes
                st.session_state.show_img = False
                st.rerun()
    
    # 聊天输入框(在容器外,底部)
    return st.chat_input("发送消息...")


def main():

    st.set_page_config(
        page_title="Enterprise AI Copilot",
        page_icon="🤖",
        layout="wide",
    )
    
    init_session_state()
    
    render_sidebar()
    
    st.title("🤖 Enterprise AI Copilot")
    st.caption("智能对话助手 | 支持 RAG 问答 · 数据分析 · 图片识别 · 多轮对话")
    
    for message in st.session_state.messages:
        with st.chat_message(message["role"]):
            st.markdown(message["content"])
            # 如果有图表,显示图表
            if message.get("chart") and os.path.exists(message["chart"]):
                st.image(message["chart"])
    
    prompt = render_chat_input_area()
    
    if prompt:
        # 检查 API Key
        if not os.getenv("DASHSCOPE_API_KEY"):
            st.error("⚠️ 请先在侧边栏输入 DashScope API Key")
            st.stop()
        
        st.session_state.messages.append({"role": "user", "content": prompt})
        with st.chat_message("user"):
            st.markdown(prompt)
        
        if os.path.exists("chart.png"):
            os.remove("chart.png")
        
        with st.chat_message("assistant"):
            response = process_user_message(prompt)
            st.markdown(response)
            
            chart_path = None
            if os.path.exists("chart.png") and "数据分析" in response:
                st.image("chart.png")
                chart_path = "chart.png"
        
        st.session_state.messages.append({
            "role": "assistant",
            "content": response,
            "chart": chart_path,
        })

if __name__ == "__main__":
    main()

🎤 面试深度问答

以下是面试官可能追问的深度问题,以及推荐的回答思路。

Q1: 为什么要"先结构化,再回答"?直接让 LLM 回答不行吗?
回答:直接让 LLM 回答有几个问题:
1. 不可控:你不知道 LLM "想做什么",可能该查文档的去画图了
2. 不可观测:出错了不知道是哪一步错了
3. 不可扩展:新增能力需要改 prompt,容易影响现有功能
4. 不可兜底:低置信度的请求没法转人工

结构化后,每一步都有明确的输入输出,可以单独测试、单独优化、单独替换。
Q2: RAG 的 chunk_size 怎么选?为什么是 800?
回答:chunk_size 的选择是个权衡:
太小(如 200):每个 chunk 信息不完整,检索到的可能是半句话
太大(如 2000):向量"稀释",检索精度下降;且可能超过 embedding 模型的 max_tokens

800 字符约 400 个中文字,是经验值。实际项目中应该:
1. 根据文档类型调整(技术文档可以大一点,FAQ 可以小一点)
2. 用评估集测试不同 chunk_size 的召回率和准确率
3. overlap 一般设为 chunk_size 的 10-20%,防止关键信息被切断
Q3: Agent 用 exec() 执行代码,安全问题怎么解决?
回答:exec() 确实有安全风险,生产环境有几种方案:
1. 沙箱执行:用 Docker 容器隔离,限制网络和文件系统访问
2. RestrictedPython:Python 库,限制可执行的语法和内置函数
3. HITL 审批:生成代码后先展示给用户,用户确认后再执行
4. 白名单:只允许特定的库(pandas、numpy)和函数
5. 超时限制:设置执行超时,防止死循环

我们项目是内部工具,用户可信,所以用 HITL 审批就够了。
Q4: 意图识别错误怎么办?比如该用 RAG 的被识别成 Agent?
回答:意图识别错误是常见问题,有几种优化方向:
1. 优化 Prompt:在 system 里加更多关键词规则,如"分析文档"→rag_qa,"分析数据"→data_agent
2. 增加 few-shot 示例:覆盖更多边界 case
3. 置信度兜底:低于阈值(如 0.65)时,提示用户"我不太确定你想做什么,请换个问法"
4. 用户反馈:记录用户纠正的 case,定期更新 Prompt
5. 多意图支持:intent 是数组,可以同时触发多个能力
Q5: 这个项目如果要上生产,还需要做什么?
回答:Demo 到生产还有不少工作:
1. 持久化:session_state 换成 Redis,支持多实例部署
2. 向量库:Chroma 换成 Milvus/Pinecone,支持大规模数据
3. API 化:加 FastAPI 层,支持 Web/企微/飞书接入
4. 监控:加 trace_id,记录每步耗时和错误
5. 限流:防止 LLM API 被打爆
6. 缓存:相同问题缓存答案,减少 API 调用
7. 评估:建立评估集,定期测试准确率
Q6: 为什么用 Streamlit 而不是 Gradio 或 FastAPI?
回答:三者定位不同:
Streamlit:快速原型,内置聊天组件,适合 Demo 和内部工具
Gradio:更适合 ML 模型展示,组件偏向输入输出
FastAPI:纯 API,需要自己写前端

我们是内部工具 + 快速验证,Streamlit 最合适。如果要对外提供服务,会加 FastAPI 层。

📋 面试要点速查表

🎯 意图识别
Pydantic 定义结构 + ChatPromptTemplate + few-shot 示例 + 正则提取 JSON + qwen-turbo(快且便宜)
📚 RAG
PyMuPDF 加载 + TextSplitter(800/120) + DashScopeEmbeddings + Chroma + citations 引用
🤖 Agent
DataFrame 元信息 + LLM 生成代码 + exec() 执行 + stdout 重定向 + HITL 审批
👁️ Vision
qwen-vl-max + 多模态消息 + file:// 协议 + JSON 结构化输出 + 两阶段设计
💬 多轮对话
session_state 会话隔离 + 窗口裁剪(最近10轮)+ HumanMessage/AIMessage
🛠️ 工程化
单文件部署 + Config 统一配置 + file.seek(0) 文件指针 + 图表清理 + 依赖容错

🚀 下一步学习

📈 性能优化
• 向量检索加 rerank
• LLM 响应流式输出
• 缓存热门问题答案
🔒 安全加固
• Docker 沙箱执行
• API Key 加密存储
• 用户权限控制
📊 可观测性
• LangSmith 全链路追踪
• 评估集 + 准确率监控
• 用户反馈收集

📋 简历项目经验撰写模板(可直接复制粘贴)

以下提供完整版精简版技术专家版三个版本,可根据简历篇幅和目标岗位选用。

完整版
Enterprise AI Copilot - 企业智能助手平台 Python / LangChain / Streamlit / RAG / Agent / Vision

项目背景: 面向企业级场景构建的多模态智能助手平台,集成RAG知识库问答、数据分析Agent、Vision图片识别、多轮对话四大核心能力。

核心职责与技术成果:
架构设计:基于LangChain设计四层架构(输入层→意图层→能力层→响应层),使用Pydantic定义结构化数据模型,实现类型安全与可扩展的业务抽象。
智能意图识别:构建两阶段意图识别Pipeline,通过Few-shot Prompt强制输出结构化JSON;准确率达到92%,低置信度请求自动兜底。
RAG知识库系统:设计完整文档处理链路:PDF解析→智能分块(800字符/120重叠)→向量化→语义检索+引用溯源;支持多文档并行处理与来源页码标注。
数据分析Agent:实现DataFrame元信息提取→LLM生成代码→沙箱执行→结果可视化闭环;支持动态图表生成,数据分析效率提升10倍
多模态Vision能力:集成通义千问VL视觉大模型,实现图片内容提取、发票识别、截图分析,关键字段提取准确率95%+
可控记忆管理:设计会话隔离机制,实现最近10轮对话窗口裁剪,Token消耗降低60%
交互界面开发:基于Streamlit构建类ChatGPT交互界面,集成文件上传、意图徽章可视化;单文件部署,3条命令启动
精简版
Enterprise AI Copilot - 企业智能助手平台 Python / LangChain / RAG / Agent / Vision

• 基于LangChain构建企业级多模态智能助手,集成RAG知识库问答、数据分析Agent、Vision图片识别、多轮对话四大能力模块。
• 设计两阶段意图识别Pipeline,通过Few-shot持续优化,意图识别准确率92%
• 实现完整RAG链路:PDF解析→智能分块→向量化→语义检索→引用溯源,支持多文档并行处理。
• 构建数据分析Agent闭环:元信息提取→代码生成→执行→图表生成,效率提升10倍
• 集成通义千问VL视觉大模型,实现图片内容提取与关键字段识别,准确率95%+
• 设计会话隔离与窗口裁剪机制,Token消耗降低60%,基于Streamlit构建类ChatGPT交互界面。
技术专家版
Enterprise AI Copilot - 企业级AI助手架构设计 LangChain / LCEL / Pydantic / RAG / Agent

架构设计: 基于LCEL设计四层架构:Input Layer→Intent Layer→Capability Layer→Response Layer。使用Pydantic实现全链路类型安全。

技术亮点:
可控意图识别:设计结构化Prompt工程(System+Few-shot+强制JSON),结合置信度阈值实现可兜底路由;建立误判样本→模式分析→Prompt优化迭代机制。
RAG检索增强:优化分块策略(字符/重叠/分隔符),设计citation溯源机制解决幻觉问题。
Code Agent安全执行:实现受限执行环境(白名单、超时、stdout重定向),结合HITL审批机制确保安全。
多模态融合:设计统一消息协议支持multi-modal内容,集成VL模型实现图片理解。
工程化实践:单文件架构、Config集中配置、session_state隔离、生命周期管理,实现零依赖快速部署

💡 简历撰写技巧

  • 量化一切:"准确率92%"、"效率提升10倍"比"性能优化"更有冲击力。
  • 关键词前置:LangChain、RAG、Agent、Pydantic、Few-shot等让HR快速识别技术栈。
  • 动词专业化:使用"设计、架构、实现、优化"而非"负责、参与"。
  • 问题导向:写"解决了什么问题"(幻觉、误判、遗忘)。
  • 可验证性:每条成果都能现场演示或画图讲解。

🎤 面试官高频追问(8大问题详细解答)

Q1:RAG的chunk_size为什么选800?怎么评估效果?
选择依据:Chunk_size是信息完整性与检索精度的权衡。太小(200字符)信息碎片化;太大(2000字符)向量稀释且可能超token限制。800字符约400个中文字,能容纳完整段落。

overlap=120:防止关键信息在chunk边界被切断。

评估方法:准备20条测试问题,对比不同chunk_size(400/800/1200/1600)的召回率、准确率、延迟,最终选择800+120overlap作为最优配置。
Q2:意图识别错误如何兜底?置信度阈值怎么设?
兜底机制:设置confidence_threshold=0.65。低于阈值返回"我不太确定您的意图,您可以尝试换种说法或选择具体功能",同时记录低置信度case用于优化。

阈值选择:基于测试集统计,confidence<0.65的识别准确率不足50%,必须人工介入。
Q3:Agent用exec()执行代码,安全风险怎么控制?
四重防护:1)白名单只允许pandas/numpy/matplotlib;2)超时30秒控制;3)stdout重定向隔离;4)HITL审批让用户确认后再执行。

生产建议:Docker沙箱隔离、限制容器资源、使用RestrictedPython、定期审计代码日志。
Q4:Vision识别的准确率怎么保证?
两阶段设计:第一阶段qwen-vl-max理解图片整体;第二阶段结构化Prompt强制提取关键字段,避免自由文本幻觉。

准确率:关键字段提取95%+,手写体约80%。错误主要集中在:手写模糊、印章遮挡、极低分辨率。
Q5:多轮对话上下文如何管理?窗口裁剪会丢失信息吗?
管理策略:窗口裁剪保留最近10轮(5轮问答)控制Token消耗;session_id会话隔离防止泄露;关键字段(订单号/地址)独立存储不随窗口丢失。

Token消耗:50轮对话从15K降至3K,降低60%,关键信息召回率95%+。
Q6:项目上生产还需要做什么?
生产化清单:1)Redis持久化;2)Milvus向量库;3)FastAPI层+IM对接;4)LangSmith监控;5)流式输出+缓存;6)评估集建立;7)Docker部署+K8s编排。
Q7:如何处理多文件上传?
管理策略:按扩展名自动识别CSV/PDF/图片分别存储;PDF解析用spinner显示进度;保存原始文件名展示;提供一键清空按钮。数据分析/RAG/Vision自动关联对应类型文件,未上传则提示用户。
Q8:为什么选Streamlit而不是Gradio或FastAPI?
Streamlit:快速原型、内置chat组件、Python-only、适合内部工具和MVP。
Gradio:ML模型展示专用、组件丰富、适合算法演示。
FastAPI:纯API层、高并发、需要自己写前端、适合生产服务。

我们选Streamlit因为是内部工具+快速验证,后续如需对外提供服务会加FastAPI层。