Assistant personnel multi-outils LangChain

Cet article construit un Agent assistant personnel qui intègre la requête météo, la gestion d'agenda et l'envoi d'e-mails, démontrant l'utilisation complète de la collaboration multi-outils et de la sortie structurée.


Conception de systèmes

  • Trois outils : requête météo, gestion d'agenda, envoi d'e-mails
  • Sortie structurée : résumé du planning formaté en Markdown
  • Sortie en streaming : affichage en temps réel de la réflexion et du traitement de l'IA
  • Mémoire de conversation : se souvenir des préférences et du contexte de l'utilisateur

Code complet

Avant de lancer, installez les dépendances, et.envconfigurez-le correctement.DEEPSEEK_API_KEY:

pip install langchain langchain-deepseek langgraph-checkpoint-sqlite python-dotenv

Exemple

# Chemin du fichier : personal_assistant.py
from dotenv import load_dotenv
load_dotenv()

import sqlite3
from datetime import datetime
from pydantic import BaseModel, Field

from langchain.tools import tool
from langchain.agents import create_agent
from langchain.agents.middleware import dynamic_prompt
from langchain.chat_models import init_chat_model
from langchain.messages import HumanMessage
from langgraph.checkpoint.sqlite import SqliteSaver


# ========== 1. Données simulées ==========

calendar_events = [
    {"id": 1, "title": "Python Study", "date": "2024-03-25",
     "time": "14:00", "duration": "2 hours"},
    {"id": 2, "title": "Réunion hebdomadaire d'équipe", "date": "2024-03-25",
     "time": "10:00", "duration": "1 heure"},
    {"id": 3, "title": "Revue de code", "date": "2024-03-26",
     "time": "15:00", "duration": "1.5 heures"},
]

weather_db = {
    "Hangzhou": {"condition": "Ensoleillé", "temp": 25, "humidity": 60},
    "Beijing": {"condition": "Nuageux", "temp": 18, "humidity": 45},
    "Shanghai": {"condition": "Pluie légère", "temp": 22, "humidity": 80},
}


# ========== 2. Définir les outils ==========

@tool
def get_weather(city: str) -> str:
    """Interroger la météo en temps réel d'une ville spécifiée.

    Arguments :
city: Nom de la ville, par exemple Hangzhou, Beijing, Shanghai
    """

    data = weather_db.get(city)
    if not data:
        return f"La météo pour {city} n'est pas prise en charge actuellement. Villes prises en charge : {', '.join(weather_db.keys())}"
    return (f"Météo de {city} : {data['condition']},"
            f"Température {data['temp']}°C, humidité {data['humidity']}%")


@tool
def query_schedule(date: str = None) -> str:
    """Interroger le planning pour une date spécifiée. Si aucune date n'est spécifiée, interroger le planning d'aujourd'hui.

    Arguments :
date: Date, format YYYY-MM-DD, par ex., 2024-03-25. Si non fournie, interroger aujourd'hui
    """

    if date is None:
        date = datetime.now().strftime("%Y-%m-%d")

    events = [e for e in calendar_events if e["date"] == date]
    if not events:
        return f"{date} n'a pas de planning."

    events.sort(key=lambda e: e["time"])
    # Ici, vous devez écrire directement le caractère emoji, et non une entité HTML comme 📅 —
    # Il s'agit d'une chaîne Python ; elle sera imprimée telle quelle et ne sera pas interprétée comme un graphique par le navigateur
    lines = [f"📅 {date} Programme :"]
    for e in events:
        lines.append(f" - {e['time']} {e['title']}({e['duration']})")
    return "\n".join(lines)


@tool
def send_email(to: str, subject: str, body: str) -> str:
    """Envoyer un e-mail (simulé).

    Arguments :
to: e-mail du destinataire
subject: Objet de l'e-mail
body: Corps de l'e-mail
    """

    # Simuler l'envoi
    email_id = f"MSG-{datetime.now().strftime('%Y%m%d%H%M%S')}"
    return f"E-mail envoyé ! Destinataire : {to}, Objet : {subject}, ID de l'e-mail : {email_id}"


# ========== 3. Modèle de sortie structuré ==========

class DailySummary(BaseModel):
    """Résumé quotidien"""
    date: str = Field(description="Date")
    weather_summary: str = Field(description="Aperçu météo")
    event_count: int = Field(description="Nombre d'événements")
    key_events: list[str] = Field(description="Liste des événements importants")
    suggestion: str = Field(description="Suggestion du jour")


def to_markdown(summary: DailySummary) -> str:
    """Rendre le DailySummary structuré sous forme de texte Markdown,
correspondant à l'élément « Sortie structurée : résumé du planning formaté en Markdown » de la conception du système."""

    lines = [
        f"## {summary.date} Résumé du jour",
        "",
        f"- **Météo** : {summary.weather_summary}",
        f"- **Nombre d'événements** : {summary.event_count}",
        "",
        "**Éléments importants :**",
    ]
    if summary.key_events:
        lines += [f"- {event}" for event in summary.key_events]
    else:
        lines.append("- None")
    lines += ["", f"**Suggestion du jour** : {summary.suggestion}"]
    return "\n".join(lines)


# ========== 4. Définir le middleware ==========

@dynamic_prompt
def inject_date_context(request) -> str:
    """Ajoute dynamiquement les informations sur la date actuelle après le prompt système.

L'approche précédente consistant à insérer le message de date dans la liste de messages avec @before_model avait deux problèmes : 72. 1. La mise à jour de message renvoyée par before_model passe par le reducer add_messages, qui ne fait que
1. Les mises à jour de message renvoyées par before_model passent par le reducer add_messages ; le reducer uniquement selon
« ajoute les nouveaux messages à la fin », en ignorant la position dans la liste renvoyée ; insert(-1, ...)
l'intention d'insérer avant le message utilisateur n'a en réalité aucun effet ;
2. before_model est déclenché à plusieurs reprises dans la boucle d'appels d'outils à plusieurs tours, ce qui peut facilement insérer ce message
entre AIMessage(tool_calls=...) et son ToolMessage correspondant,
ce qui perturbe l'exigence stricte d'ordre des messages.
À la place, utilisez @dynamic_prompt pour réécrire directement la chaîne de prompt système ; chaque appel de modèle
la recalculera, sans toucher à la liste de messages, de sorte qu'il n'y a aucun risque d'accumulation ou de désalignement.
    """

    now = datetime.now()
    weekday = ["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"][now.weekday()]
    date_hint = (f"\n\n[Prompt système] La date actuelle est {now.strftime('%Y-%m-%d')},
                 f"Jour de la semaine {weekday}. Si l'utilisateur ne spécifie pas de date, interrogez la date du jour par défaut.")
    return request.system_prompt + date_hint


# ========== 5. Créer un agent ==========

# Utilisez SqliteSaver pour persister les conversations, en implémentant la « mémoire de conversation » dans la conception du système.
# Établissez vous-même la connexion et passez-la au constructeur SqliteSaver, plutôt que d'utiliser
# SqliteSaver.from_conn_string() (qui est un gestionnaire de contexte adapté uniquement aux instructions with,
# un gestionnaire de contexte qui se ferme après utilisation ; voir l'article « Robot de service client intelligent LangChain »).
conn = sqlite3.connect("personal_assistant.db", check_same_thread=False)
checkpointer = SqliteSaver(conn)

model = init_chat_model("deepseek:deepseek-v4-flash", temperature=0.3)
agent = create_agent(
    model=model,
    tools=[get_weather, query_schedule, send_email],
    middleware=[inject_date_context],
    response=DailySummary,
    checkpointer=checkpointer,
    system_prompt="""Vous êtes un assistant personnel nommé "Xiao Zhu". Vous pouvez vérifier la météo, gérer les plannings et envoyer des e-mails.

## Comment ça fonctionne
1. Lorsque l’utilisateur demande « Comment se passe aujourd’hui ? » ou des questions similaires :
- D’abord interroger la météo d’aujourd’hui (get_weather)
- Ensuite interroger le planning d’aujourd’hui (query_schedule)
- Puis générer un résumé quotidien

2. Lorsque l’utilisateur demande d’envoyer un e-mail, utilisez l’outil send_email

3. Lorsque l’utilisateur demande uniquement la météo ou uniquement le planning, appelez uniquement l’outil correspondant

## Style
- Le ton est amical et naturel
- Préférez utiliser des outils pour obtenir des données en temps réel ; n'inventez rien"""
,
)


# ========== 6. Fonction d'interaction ==========

def chat(message: str, thread_id: str = "xiaoming"):
    """Discuter avec l'assistant :
- Utilisez stream_mode="values" pour diffuser la réflexion et le traitement de l'IA (correspondant à « sortie en flux »)
- Transmettez thread_id ; les conversations sous le même thread_id seront mémorisées par SqliteSaver (correspondant à « mémoire de conversation »)
- Si un résumé structuré est généré dans ce tour, il sera rendu en Markdown et imprimé (correspondant à « sortie structurée »)
    """

    config = {"configurable": {"thread_id": thread_id}}
    print(f"\n{'='*60}")
    print(f"Vous : {message}")
    print(f"{'='*60}")

    seen = 0
    final_state = {}
    for state in agent.stream(
        {"messages": [HumanMessage(content=message)]},
        config=config,
        stream_mode="values",
    ):
        final_state = state
        msgs = state.get("messages", [])
        # Chaque fois que quelques nouveaux messages de plus apparaissent, cela signifie que l'Agent a avancé d'une étape ; affichez-les en temps réel
        for msg in msgs[seen:]:
            if msg.type == "ai" and getattr(msg, "tool_calls", None):
                for call in msg.tool_calls:
                    print(f"🤔 A décidé d'appeler l'outil : {call['name']}, arguments : {call['args']}")
            elif msg.type == "tool":
                tool_name = getattr(msg, "name", "") or "Outil"
                print(f"🔧 {tool_name} a renvoyé : {str(msg.content)[:80]}")
            elif msg.type == "ai" and msg.content:
                print(f"🤖 Assistant : {msg.content}")
        seen = len(msgs)

    # Remarque : response_format est configuré au niveau de l'Agent ; chaque appel (y compris l'envoi d'e-mails,
    # les demandes de suivi sans rapport avec "schedule summary") tenteront de force de générer un DailySummary,
    # Il s'agit d'une limitation connue du response_format global. Pour créer véritablement un assistant multi-intentions, une meilleure approche consiste
    # de faire de "generate daily summary" un outil séparé, en laissant l'Agent décider s'il faut l'appeler,
    # plutôt que d'attacher un schéma de sortie toujours actif à l'ensemble de l'Agent.
    if "structured_response" in final_state:
        summary = final_state["structured_response"]
        print("\n--- Résumé structuré (Markdown) ---)
        print(to_markdown(summary))

    return final_state


# ========== 7. Tests ==========

if __name__ == "__main__":
    chat(Quel temps fait-il à Hangzhou aujourd'hui ? Consulte mon emploi du temps, puis donne-moi un résumé pour aujourd'hui)
    chat(Aide-moi à envoyer un e-mail à [email protected], avec pour objet « Résumé quotidien » et un contenu indiquant que l'emploi du temps d'aujourd'hui a été confirmé)
    # Troisième tour : aucune nouvelle information, on teste uniquement si l'Agent se souvient de la conversation précédente —
    # Comme le même thread_id est transmis, le checkpointer ramènera l'historique complet
    chat(Quel était l'objet de l'e-mail que je t'ai demandé d'envoyer tout à l'heure ?)

Résultat de l'exécution :

============================================================
You: 杭州今天天气怎么样?看看我的日程,然后给我一个今日总结
============================================================
🤔 决定调用工具: get_weather,参数: {'city': '杭州'}
🔧 get_weather 返回: 杭州天气:晴,温度 25°C,湿度 60%
🤔 决定调用工具: query_schedule,参数: {}
🔧 query_schedule 返回: 📅 2024-03-25 日程安排:  - 10:00 团队周会(1小时)  - 14:00...
🤖 助手: 早上好!今天杭州是大晴天,气温 25°C,湿度 60%,很适合出门活动。
今天您有两项日程:上午 10:00 的团队周会和下午 14:00 的 Python 学习,祝您今天顺利!

--- 结构化摘要(Markdown) ---
## 2024-03-25 今日摘要

- **天气**:杭州晴,25°C,湿度 60%
- **日程数量**:2

**重要事项:**
- 10:00 团队周会
- 14:00 Python 学习

**今日建议**:上午先参加团队周会,下午集中精力学习 Python,注意劳逸结合

============================================================
You: 帮我发一封邮件给 [email protected],主题是'今日总结',内容是今天日程已确认
============================================================
🤔 决定调用工具: send_email,参数: {'to': '[email protected]', 'subject': '今日总结', 'body': '今天日程已确认'}
🔧 send_email 返回: 邮件已发送!收件人:[email protected],主题:今日总结,邮件ID:MSG-20240325143000
🤖 助手: 邮件已经发送成功啦!收件人是 [email protected],主题"今日总结"。

--- 结构化摘要(Markdown) ---
## 2024-03-25 今日摘要
...(这里的摘要和邮件内容其实没什么关系,是 response_format 强制生成的,
     属于前面提到的已知局限)

============================================================
You: 我刚才让你发的那封邮件,主题是什么来着?
============================================================
🤖 助手: 你刚才让我发的那封邮件主题是"今日总结",收件人是 [email protected].

Le troisième tour n'a déclenché aucun appel d'outil du tout. L'assistant a pu répondre uniquement parce quethread_idle même SqliteSaver a ramené l'historique complet des messages des deux premiers tours — voici ce à quoi ressemble vraiment la « mémoire de conversation » en action. Si vous le changezthread_idpour une nouvelle valeur, le troisième tour devient une conversation isolée, et l'assistant ne saura pas ce qui s'est passé « tout à l'heure ».


Résumé du projet

Cet assistant personnel démontre :

FonctionnalitéImplémentation
Collaboration multi-outilsMétéo + emploi du temps + e-mail ; l'Agent choisit automatiquement l'ordre d'appel
Sortie structuréeModèle Pydantic DailySummary + to_markdown() rendu sous forme de texte Markdown
Sortie en streamingagent.stream(stream_mode="values"), affichant progressivement les appels d'outils et le processus de réflexion
Mémoire de conversationSqliteSaver + thread_id, mémorisant le contexte sur plusieurs tours d'appels
Injection de date@dynamic_prompt réécrit dynamiquement le prompt système, évitant la contamination de l'historique des messages
Interaction en langage naturelLes utilisateurs décrivent leurs besoins en langage naturel, et l'Agent planifie de manière autonome
Autres extensions