纲要
- 项目准备:开发资源获取
Python与VSCode环境配置- 大模型
API Key获取(硅基流动、CloseAI) GitHub Copilot开启 AI 辅助编程- 钉钉开放平台应用创建与
Stream模式配置 LangSmith可观测性追踪开通
- 项目架构概览
- 目录结构
- 核心模块关系(
Mermaid流程图)
- 实战开发:从入口到智能体
- 环境依赖与
Poetry管理 .env配置文件详解- 日志系统初始化
- 钉钉
WebSocket入口实现 - 智能体核心类
AIAgent- 主/备模型回退机制
- 工具加载与记忆系统
- 情感分析与动态提示词
- 多用户记忆隔离(
session_id)
- 完整可运行代码示例
- 环境依赖与
- 总结与相关度说明
项目准备:资源一站式获取
在动手写代码之前,我们需要先把开发环境、模型 API、钉钉应用等资源全部备齐。
| 资源类型 | 获取方式 | 用途 |
|---|---|---|
Python 3.10+ |
python.org 下载安装 | 运行环境 |
VSCode |
code.visualstudio.com 下载 | 主力 IDE |
GitHub Copilot |
VSCode 内购订阅(推荐专业版) | AI 辅助编码、调试 |
| 大模型 API | 硅基流动(siliconflow.cn)或 CloseAI | 提供 LLM 推理能力 |
搜索引擎 SerpAPI |
serpapi.com 注册免费 key | 在线搜索工具 |
| 钉钉应用 | open.dingtalk.com 创建"钉钉应用"(非"机器人") | 消息收发、日程待办 API |
LangSmith |
smith.langchain.com 注册并创建 API Key | 调用链追踪、成本监控 |
Redis |
本地安装或 Docker 启动 | 会话记忆存储 |
硅基流动(硅基流动)与 CloseAI 使用要点
硅基流动托管了大量国产开源模型(DeepSeek、Qwen 等),其 API 地址完全兼容 OpenAI 格式,只需更换 base_url 即可。注册后完成实名认证,在"模型广场"中复制模型名称(如 deepseek-ai/DeepSeek-R1),然后在 API 文档 中获取接口地址和密钥。计费透明,按 token 消费,适合个人开发者。
CloseAI 则整合了 OpenAI、Claude、Gemini、DeepSeek 等海外模型,通过统一接口提供。需注意不同模型的 base_url 后缀不同(例如 OpenAI 使用 /v1,Claude 使用 /anthropic)。在"密钥管理"页面创建密钥,并在使用时指定正确的地址。
安全提醒 :永远不要将 API Key 明文写在代码中,应放入
.env文件并用.gitignore排除。
项目架构一览
小浪助手是一个基于 LangChain 的单智能体,通过钉钉 Stream 模式(WebSocket)与用户实时交互,具备日程管理、待办创建、知识库问答和情绪检测能力。其核心架构如下:
#mermaid-svg-kClaNMVytEXRXo5P{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-kClaNMVytEXRXo5P .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-kClaNMVytEXRXo5P .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-kClaNMVytEXRXo5P .error-icon{fill:#552222;}#mermaid-svg-kClaNMVytEXRXo5P .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-kClaNMVytEXRXo5P .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-kClaNMVytEXRXo5P .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-kClaNMVytEXRXo5P .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-kClaNMVytEXRXo5P .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-kClaNMVytEXRXo5P .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-kClaNMVytEXRXo5P .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-kClaNMVytEXRXo5P .marker{fill:#333333;stroke:#333333;}#mermaid-svg-kClaNMVytEXRXo5P .marker.cross{stroke:#333333;}#mermaid-svg-kClaNMVytEXRXo5P svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-kClaNMVytEXRXo5P p{margin:0;}#mermaid-svg-kClaNMVytEXRXo5P .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-kClaNMVytEXRXo5P .cluster-label text{fill:#333;}#mermaid-svg-kClaNMVytEXRXo5P .cluster-label span{color:#333;}#mermaid-svg-kClaNMVytEXRXo5P .cluster-label span p{background-color:transparent;}#mermaid-svg-kClaNMVytEXRXo5P .label text,#mermaid-svg-kClaNMVytEXRXo5P span{fill:#333;color:#333;}#mermaid-svg-kClaNMVytEXRXo5P .node rect,#mermaid-svg-kClaNMVytEXRXo5P .node circle,#mermaid-svg-kClaNMVytEXRXo5P .node ellipse,#mermaid-svg-kClaNMVytEXRXo5P .node polygon,#mermaid-svg-kClaNMVytEXRXo5P .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-kClaNMVytEXRXo5P .rough-node .label text,#mermaid-svg-kClaNMVytEXRXo5P .node .label text,#mermaid-svg-kClaNMVytEXRXo5P .image-shape .label,#mermaid-svg-kClaNMVytEXRXo5P .icon-shape .label{text-anchor:middle;}#mermaid-svg-kClaNMVytEXRXo5P .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-kClaNMVytEXRXo5P .rough-node .label,#mermaid-svg-kClaNMVytEXRXo5P .node .label,#mermaid-svg-kClaNMVytEXRXo5P .image-shape .label,#mermaid-svg-kClaNMVytEXRXo5P .icon-shape .label{text-align:center;}#mermaid-svg-kClaNMVytEXRXo5P .node.clickable{cursor:pointer;}#mermaid-svg-kClaNMVytEXRXo5P .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-kClaNMVytEXRXo5P .arrowheadPath{fill:#333333;}#mermaid-svg-kClaNMVytEXRXo5P .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-kClaNMVytEXRXo5P .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-kClaNMVytEXRXo5P .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-kClaNMVytEXRXo5P .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-kClaNMVytEXRXo5P .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-kClaNMVytEXRXo5P .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-kClaNMVytEXRXo5P .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-kClaNMVytEXRXo5P .cluster text{fill:#333;}#mermaid-svg-kClaNMVytEXRXo5P .cluster span{color:#333;}#mermaid-svg-kClaNMVytEXRXo5P div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-kClaNMVytEXRXo5P .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-kClaNMVytEXRXo5P rect.text{fill:none;stroke-width:0;}#mermaid-svg-kClaNMVytEXRXo5P .icon-shape,#mermaid-svg-kClaNMVytEXRXo5P .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-kClaNMVytEXRXo5P .icon-shape p,#mermaid-svg-kClaNMVytEXRXo5P .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-kClaNMVytEXRXo5P .icon-shape .label rect,#mermaid-svg-kClaNMVytEXRXo5P .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-kClaNMVytEXRXo5P .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-kClaNMVytEXRXo5P .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-kClaNMVytEXRXo5P :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} WebSocket
文本消息 + 用户ID
钉钉用户
入口: dingtalk_hook.py
AIAgent 核心类
情感分析链
动态提示词管理
Redis 记忆系统
主模型 OpenAI / 备用 DeepSeek
工具集
知识库检索
在线搜索
钉钉日历/待办 API
生成回复
用户消息经钉钉 WebSocket 传入后,被入口函数捕获,提取文本和发送者唯一 ID。AIAgent 实例首先分析情绪,据此动态调整提示词,然后调用绑定了工具与记忆的 AgentExecutor,最终生成回复并返回给用户。
实战开发:从零编写核心代码
环境初始化与项目结构
使用 Poetry 管理依赖,确保环境干净可复现。在项目根目录执行:
bash
mkdir xiaolang-dingtalk && cd xiaolang-dingtalk
poetry init -n
poetry add langchain langchain-openai langchain-community python-dotenv redis pydantic \
dingtalk-stream chromadb tiktoken
项目目录结构如下:
dir
xiaolang-dingtalk/
├── src/
│ ├── dingtalk_hook.py # 钉钉入口,WebSocket 回调
│ ├── agent.py # AI智能体核心类
│ ├── tools/
│ │ ├── __init__.py
│ │ ├── calendar.py # 钉钉日历工具
│ │ ├── task.py # 钉钉待办工具
│ │ ├── knowledge.py # RAG 知识库检索
│ │ └── search.py # 在线搜索
│ ├── chains/
│ │ └── emotion.py # 情感检测链
│ └── memory/
│ └── redis_memory.py # Redis 记忆封装
├── .env # 环境变量(不提交仓库)
├── pyproject.toml
└── README.md
配置环境变量 .env
env
# LLM 主模型
OPENAI_API_KEY=sk-xxxx
OPENAI_BASE_URL=https://api.siliconflow.cn/v1
DEFAULT_MODEL=deepseek-ai/DeepSeek-R1
# 备用模型
DEEPSEEK_API_KEY=sk-xxxx
DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
# 搜索引擎
SERPAPI_API_KEY=xxxx
# Redis
REDIS_URL=redis://localhost:6379/0
# 钉钉应用
DINGTALK_APP_KEY=dingxxxx
DINGTALK_APP_SECRET=xxxx
DINGTALK_ROBOT_CODE=xxxx
# LangSmith 追踪
LANGCHAIN_TRACING_V2=true
LANGCHAIN_API_KEY=ls__xxxx
LANGCHAIN_PROJECT=xiaolang-dingtalk
入口文件:钉钉消息接收与回复
使用钉钉官方 dingtalk-stream 库建立 WebSocket 连接,注册消息回调。这里简化示例,不依赖真实钉钉环境也能本地运行------我们用标准输入模拟消息收发,但完整保留了多用户记忆隔离、调用智能体的真实流程。实际部署时,只需将模拟部分替换为 DingTalkStreamClient 即可。
python
# src/dingtalk_hook.py
import os
import sys
import logging
from dotenv import load_dotenv
from agent import AIAgent
load_dotenv()
# 配置日志
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger("XiaoLang")
# 全局用户存储,模拟从钉钉回调中获取的 sender_id
user_store = {}
def get_or_create_user(sender_id: str):
if sender_id not in user_store:
user_store[sender_id] = {"id": sender_id}
return user_store[sender_id]
def main():
logger.info("小浪助手启动中...")
agent = AIAgent() # 初始化智能体
print("小浪助手已就绪,输入 'exit' 退出。")
# 模拟多用户交互:输入格式为 "用户ID: 消息内容"
while True:
try:
raw = input("> ")
if raw.lower() == "exit":
break
if ":" not in raw:
print("请输入 '用户ID: 消息内容'")
continue
sender_id, text = raw.split(":", 1)
sender_id = sender_id.strip()
text = text.strip()
# 存储或获取用户信息
get_or_create_user(sender_id)
# 调用智能体
reply = agent.run_agent(text, sender_id)
print(f"小浪助手 -> {sender_id}: {reply}")
except KeyboardInterrupt:
break
except Exception as e:
logger.error(f"处理消息失败: {e}")
if __name__ == "__main__":
main()
真实钉钉连接时,将 main 函数替换为 DingTalkStreamClient 的回调注册即可,原理完全一致。
智能体核心类 AIAgent
这个类封装了模型加载、工具注册、记忆管理、情感分析和动态提示词合成的全部逻辑。
python
# src/agent.py
import os
import json
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
from langchain.agents import AgentExecutor, create_tool_calling_agent
from langchain.tools import tool
from langchain.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain.memory import ConversationBufferMemory
from langchain_community.chat_message_histories import RedisChatMessageHistory
from langchain_core.runnables import ConfigurableField
load_dotenv()
# ---------- 工具定义(简化示例) ----------
@tool
def create_calendar_event(summary: str, start_time: str, end_time: str) -> str:
"""创建钉钉日程。参数 summary: 标题, start_time: 开始时间 ISO格式, end_time: 结束时间 ISO格式"""
# 实际调用钉钉 API,此处模拟成功
return f"已创建日程「{summary}」,时间 {start_time} ~ {end_time}"
@tool
def create_task(content: str, priority: str = "normal") -> str:
"""创建钉钉待办。参数 content: 待办内容, priority: 优先级 low/medium/high"""
return f"已创建待办「{content}」,优先级 {priority}"
@tool
def web_search(query: str) -> str:
"""在线搜索最新信息"""
from langchain_community.utilities import SerpAPIWrapper
search = SerpAPIWrapper()
return search.run(query)
tools = [create_calendar_event, create_task, web_search]
# ---------- 情感分析链 ----------
emotion_prompt = ChatPromptTemplate.from_template(
"分析用户消息的情绪,返回JSON: {{\"emotion\": \"positive/neutral/negative\", \"score\": 0-10}}\n"
"消息: {input}\nJSON:"
)
class AIAgent:
def __init__(self):
# 1. 主模型(可回退到备用模型)
primary = ChatOpenAI(
model=os.getenv("DEFAULT_MODEL", "gpt-3.5-turbo"),
openai_api_key=os.getenv("OPENAI_API_KEY"),
base_url=os.getenv("OPENAI_BASE_URL"),
temperature=0
)
fallback = ChatOpenAI(
model="deepseek-chat",
openai_api_key=os.getenv("DEEPSEEK_API_KEY"),
base_url=os.getenv("DEEPSEEK_BASE_URL"),
temperature=0
)
self.llm = primary.with_fallbacks([fallback])
# 2. 记忆系统(基于 Redis,支持多用户 session)
self.base_memory = ConversationBufferMemory(
memory_key="chat_history",
return_messages=True,
input_key="input",
output_key="output"
)
# 3. 情感分析 LLM
self.emotion_llm = ChatOpenAI(model="gpt-3.5-turbo", temperature=0)
# 4. 基础提示词(动态部分通过占位符注入)
base_prompt = ChatPromptTemplate.from_messages([
("system", "你是小浪助手,一个钉钉智能客服。你可以管理日程、创建待办、在线搜索。"
"请友好专业地回复。当前用户情绪状态:{emotion_state}"),
MessagesPlaceholder("chat_history"),
("human", "{input}"),
MessagesPlaceholder("agent_scratchpad")
])
self.base_prompt = base_prompt
# 5. 构建可配置记忆的 Agent
agent = create_tool_calling_agent(self.llm, tools, base_prompt)
self.agent_executor = AgentExecutor(
agent=agent,
tools=tools,
memory=self.base_memory,
verbose=True,
handle_parsing_errors=True,
).configurable_fields(
memory=ConfigurableField(
id="agent_memory",
name="Memory",
description="可切换的记忆实例"
)
)
def detect_emotion(self, text: str) -> dict:
"""调用 LLM 分析情绪,返回 dict"""
chain = emotion_prompt | self.emotion_llm
result = chain.invoke({"input": text})
try:
return json.loads(result.content)
except:
return {"emotion": "neutral", "score": 5}
def get_memory_for_user(self, user_id: str) -> ConversationBufferMemory:
"""为特定用户创建独立的 Redis 记忆"""
history = RedisChatMessageHistory(
session_id=user_id,
url=os.getenv("REDIS_URL", "redis://localhost:6379/0")
)
return ConversationBufferMemory(
memory_key="chat_history",
chat_memory=history,
return_messages=True,
input_key="input",
output_key="output"
)
def run_agent(self, user_input: str, user_id: str = "default") -> str:
# 情绪分析
emotion = self.detect_emotion(user_input)
# 根据情绪值动态调整提示词中的状态描述
if emotion["emotion"] == "negative" and emotion["score"] >= 8:
emotion_state = "强烈负面,请主动将用户诉求创建为高优先级待办"
else:
emotion_state = f"{emotion['emotion']} (分值 {emotion['score']})"
# 合成带情绪状态的提示词
dynamic_prompt = self.base_prompt.partial(emotion_state=emotion_state)
# 重建 agent(因为提示词变化,需要新 agent)
from langchain.agents import create_tool_calling_agent
agent = create_tool_calling_agent(self.llm, tools, dynamic_prompt)
# 获取用户专属记忆
user_memory = self.get_memory_for_user(user_id)
executor = AgentExecutor(
agent=agent,
tools=tools,
memory=user_memory,
verbose=True,
handle_parsing_errors=True,
)
response = executor.invoke({"input": user_input})
return response["output"]
运行效果
确保本地 Redis 已启动,然后执行:
bash
cd src
python dingtalk_hook.py
按提示输入 user1: 帮我创建一个明天下午3点的会议,主题是 AI 进展,智能体将调用工具并回复。不同用户 ID 的消息会拥有各自独立的上下文记忆,互不干扰。
总结
本文从零开始,带领读者完成了钉钉智能助手项目的资源申请、环境搭建和核心代码编写。完整展现了如何利用 LangChain 构建一个支持多工具调用、记忆隔离、情绪感知和模型回退的单智能体,所有代码均可直接复制运行。
下一步,你可以继续扩展工具集、接入真实钉钉 Stream 回调,或迁移到 LangGraph 实现更复杂的多智能体协作。