AI Agent白手起家52: 从零搭建钉钉智能助手——资源准备与核心架构实现

纲要

  • 项目准备:开发资源获取
    • PythonVSCode 环境配置
    • 大模型 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 实现更复杂的多智能体协作。

相关推荐
冬奇Lab1 小时前
代码库知识库系列(13):评测——怎么知道知识库够不够好
人工智能
字节跳动视频云技术团队1 小时前
把 AI 视频的钱花在刀刃上,不是每一刀上
人工智能·音视频开发
jufeng13071 小时前
【系列:手搓自主 AI Agent:Hermes 架构原理剖析 · 第 1 篇】
人工智能·python·架构·agent
制造业的搬运工1 小时前
智能窗帘PCB低功耗设计方案:架构要点与设计建议
人工智能·科技·架构·制造·pcb工艺
意图共鸣1 小时前
意图共鸣科技8月10日正式发布《AI协作记忆系统 · 认知架构白皮书》
人工智能·科技·microsoft
牧羊人.3332 小时前
计算机视觉基础|第2章 OpenCV图像基础操作(读写、窗口、像素操作)
人工智能·opencv·计算机视觉
带娃的IT创业者2 小时前
DeepTutor:当 Agent-Native 架构撞上个性化学习的临界点
学习·架构·ai agent·大模型应用·个性化学习·教育技术·agent-native架构
avi91112 小时前
[AI教做人]AI平台做2项目;一个3D模型展示,另一个框架多人
javascript·人工智能·ai·3d模型·3d引擎·顶点和法线
焱童鞋2 小时前
基于DJL的LSTM水文预报模型训练完整指南
人工智能·rnn·lstm