融合第3/5/7/8章能力:多轮对话 + RAG知识库 + 数据分析Agent + 多模态Vision → 一个完整可运行的企业级智能助手
面试官看的不是"你会不会 LangChain",而是你能不能把能力变成产品。本章把前面所有章节的精华融合成一个完整可运行的企业级智能助手。 这不是"把四个 demo 拼一起",而是把每个阶段最值钱的能力提炼出来,统一到一个可交付的产品里。
chapter_12/ ├── chatbot.py # 主入口(复制下方代码) └── requirements.txt # 依赖清单
pip install streamlit langchain langchain-community dashscope chromadb pymupdf pandas pydantic matplotlibexport DASHSCOPE_API_KEY="your_key"streamlit run chatbot.py
整个系统采用管道式架构:用户输入 → 意图识别 → 路由分发 → 能力执行 → 结果返回。 每一步都是独立的,可以单独测试、单独优化、单独替换。
┌─────────────────────────────────────────────────────────────────────────────────┐
│ 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) 避免重复读取问题
{
"intent": ["rag_qa"],
"slots": {
"query": "病假最多几天",
"file_path": "?",
"image_path": "?"
},
"confidence": 0.88
}
# 意图枚举:只允许这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~1Literal 约束枚举值,LLM 输出 "rag" 会校验失败Field(ge=0.0, le=1.0) 约束数值范围model_validate() 自动校验 + 类型转换
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:"),
])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 校验```json {...} ``` 格式,需要正则提取纯 JSON。PDF ↓ PyMuPDFLoader Documents ↓ TextSplitter Chunks (800字/块) ↓ Embeddings Vectors ↓ Chroma VectorStore ↓ Retriever(k=4) 相关文档 ↓ LLM 带引用的答案
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:按优先级切分,优先在段落/句子边界切分,保持语义完整
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:生成任务用低温度,减少"创造性"
CSV
↓ pd.read_csv()
DataFrame
↓ 元信息提取
{shape, columns, sample}
↓ LLM (qwen-max)
Pandas 代码
↓ exec()
执行结果 + chart.png
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 生成代码
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 才导入,避免不必要的依赖
print() 语句来输出分析结果。如果不重定向输出,这些内容会直接打印到服务器控制台,而不是返回给用户。
""(空字符串)数据统计:
总行数:1000
平均值:25.6
"数据统计:
总行数:1000
平均值:25.6"(无额外输出)
sys.stdout 仍然指向 StringIO,会导致后续所有 print() 都不显示在终端,造成调试困难。
exec() 可以执行任意 Python 代码,恶意用户可能注入危险代码(如 os.system("rm -rf /")){
"summary": "这是一张增值税发票",
"entities": [
"金额: ¥1,234.56",
"日期: 2024-01-15",
"开票方: XX公司"
],
"answer": "发票金额是1234.56元"
}
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,3,...,15] 窗口大小: 10 注入模型: [6,7,8,9,10,11,12,13,14,15] 好处: • 控制 Token 预算 • 保留最近上下文 • 避免"记住太早的废话"
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 消息
file.seek(0) 重置指针,并用 session_state 缓存已处理的文件。
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# 处理新消息前清理旧图表
if os.path.exists("chart.png"):
os.remove("chart.png")
# 只有数据分析时才显示图表
if os.path.exists("chart.png") and "数据分析" in response:
st.image("chart.png")```json {...} ```,有时输出纯 JSON,有时还加解释文字。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} # 兜底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")chapter_12/chatbot.pystreamlit>=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完整代码,可直接复制使用: