Module 03 · Leçon 1

Conversation à plusieurs tours : le modèle ne se souvient de rien

Écrire un programme de discussion en ligne de commande, et constater que le modèle n'a pas de mémoire – la « mémoire », c'est simplement renvoyer l'historique à chaque fois. Puis comparer deux façons de gérer un historique trop long, la troncature et le résumé.

  • Environ 35 minutes
  • Niveau : Débutant
  • Testé : 2026-09-14 deepseek-flash

Le code et les sorties des programmes sont reproduits tels qu’ils ont tourné : commentaires et sorties sont donc en chinois.

Quiconque a utilisé un chatbot a cette intuition : il se souvient de ce que j'ai dit. Vous lui dites « Je m'appelle Xiao Wang », quelques phrases plus loin vous lui demandez « Comment je m'appelle ? », et il sait répondre.

Cette intuition est fausse, du moins pour l'API. Le modèle lui-même ne se souvient de rien ; chaque appel repart de zéro. Si le chatbot « se souvient », c'est parce que le programme renvoie à chaque fois toute la conversation précédente au modèle. Dans cette leçon, nous écrivons notre propre programme de discussion pour voir cela clairement.

D'abord, constater qu'il ne se souvient pas

code/03-llm-apps/conversation.py est un programme de discussion en ligne de commande ; avec le paramètre --stateless, il n'envoie à chaque fois que la phrase courante, sans historique. Je lui donne deux phrases par un tube :

printf '%s\n' "我叫小王,最近在用 httpx 写一个爬虫。" "我叫什么名字?" | python conversation.py --stateless
你:我叫小王,最近在用 httpx 写一个爬虫。
助手:好的,httpx 很适合爬虫。有什么具体问题需要帮忙吗?  (本轮输入 32 词元)
你:我叫什么名字?
助手:你没有告诉我你的名字。  (本轮输入 23 词元)

Du point de vue du modèle, le second appel ne contient qu'une phrase, « 我叫什么名字? » (comment je m'appelle ?) ; il ne peut évidemment pas le savoir.

La mémoire, c'est la liste de messages

Pour qu'il « se souvienne », il suffit de mettre toute la conversation précédente dans messages. Le cœur du programme tient en quelques lignes :

SYSTEM = {"role": "system", "content": "你是一个说话简短的编程助手,每次回答不超过三句话。"}
history = []  # 只放 user 和 assistant 消息,system 每次单独加在最前面

while True:
    user_text = input("你:").strip()
    if not user_text:
        break
    messages = [SYSTEM] + history + [{"role": "user", "content": user_text}]
    response = client.chat.completions.create(model=MODEL, messages=messages, extra_body=NO_THINKING)
    answer = response.choices[0].message.content
    print(f"助手:{answer}  (本轮输入 {response.usage.prompt_tokens} 词元)")
    history += [{"role": "user", "content": user_text}, {"role": "assistant", "content": answer}]

À chaque tour, ce que dit l'utilisateur est ajouté à history comme message user, et la réponse du modèle comme message assistant. Au tour suivant, tout history est envoyé avec la nouvelle question.

Je lui donne six phrases :

你:我叫小王,最近在用 httpx 写一个爬虫。
助手:好的,httpx 支持同步和异步请求,写爬虫时注意设置 timeout 和重试。有什么具体问题吗?  (本轮输入 32 词元)
你:我叫什么名字?
助手:你叫小王。  (本轮输入 68 词元)
你:我在用什么库做什么?
助手:你在用 httpx 写爬虫。  (本轮输入 81 词元)
你:推荐一个解析 HTML 的库。
助手:推荐 parsel 或 BeautifulSoup,异步场景可选 selectolax。  (本轮输入 102 词元)
你:它和 httpx 怎么配合用?一句话。
助手:把 httpx 返回的 `response.text` 传给解析库(如 `BeautifulSoup(response.text, "lxml")`)即可。  (本轮输入 132 词元)
你:我叫什么名字?
助手:小王。  (本轮输入 170 词元)

Cette fois, il se souvient du nom, et il comprend que le « il » de « 它和 httpx 怎么配合用 » (comment l'utiliser avec httpx) désigne la bibliothèque d'analyse recommandée au tour précédent.

Regardez le nombre de tokens d'entrée en fin de ligne : 32, 68, 81, 102, 132, 170, de plus en plus à chaque tour. Car chaque tour renvoie toute la conversation précédente.

L'historique ne cesse de grandir

C'est le problème central des conversations à plusieurs tours : l'historique ne fait que croître.

  • De plus en plus cher. L'entrée du tour 6 est plus de 5 fois celle du tour 1. Dans une conversation de cent tours, chaque tour paie pour les quatre-vingt-dix-neuf précédents. Heureusement, le début de l'historique est fixe et touche le cache de la leçon 4 du module 01, ce qui réduit beaucoup le coût, mais celui-ci augmente toujours.
  • De plus en plus lent. Plus l'entrée est longue, plus le modèle met de temps à la lire.
  • Un jour, la fenêtre de contexte sera dépassée. À ce moment-là, la requête échoue directement.

Tout programme de discussion réellement mis en production doit donc maîtriser la longueur de l'historique. Deux méthodes sont courantes.

Méthode 1 : la troncature

La plus simple : ne garder que les derniers messages et jeter directement les plus anciens. Le paramètre --max-messages 4 de conversation.py fait cela : quand l'historique dépasse 4 messages, seuls les 2 plus récents sont gardés.

def trim(history):
    if len(history) <= args.max_messages:
        return history
    keep = history[-(args.max_messages // 2):]
    dropped = history[: len(history) - len(keep)]
    print(f"  [丢掉了最早的 {len(dropped)} 条消息]")
    return keep

Toujours les six mêmes phrases :

你:我叫小王,最近在用 httpx 写一个爬虫。
助手:好的,小王。请问在 httpx 爬虫过程中遇到了什么问题?  (本轮输入 32 词元)
你:我叫什么名字?
助手:你叫小王。  (本轮输入 55 词元)
你:我在用什么库做什么?
助手:你在用 httpx 写爬虫。  (本轮输入 68 词元)
  [丢掉了最早的 4 条消息]
你:推荐一个解析 HTML 的库。
助手:推荐 BeautifulSoup,配合 httpx 用很方便。  (本轮输入 45 词元)
你:它和 httpx 怎么配合用?一句话。
助手:用 `BeautifulSoup(httpx.get(url).text, "html.parser")` 就能解析。  (本轮输入 71 词元)
  [丢掉了最早的 4 条消息]
你:我叫什么名字?
助手:我不知道你的名字,你还没告诉我。  (本轮输入 60 词元)

Les tokens d'entrée restent à quelques dizaines et ne grandissent plus. Le prix : à la dernière question, il a oublié que l'utilisateur s'appelle Xiao Wang, car cette phrase a été jetée depuis longtemps.

La troncature convient quand « les derniers tours comptent le plus et que l'ancien peut être oublié », par exemple pour du bavardage ou des questions-réponses ponctuelles. Dans un vrai projet, on tronque généralement selon le nombre de tokens plutôt que de messages ; ici, les messages rendent la démonstration plus simple.

Attention : il faut tronquer par paires « question-réponse ». Ne jetez pas un message user en gardant le message assistant correspondant, sinon le modèle voit une conversation mutilée. Le code ci-dessus garde toujours un nombre pair de messages, ce qui évite le problème.

Méthode 2 : le résumé

Un peu mieux : au lieu de jeter les anciens messages, demander au modèle de les condenser en un résumé :

def summarize(messages):
    text = "\n".join(f"{m['role']}: {m['content']}" for m in messages)
    response = client.chat.completions.create(
        model=MODEL,
        messages=[{"role": "user", "content": "把下面这段对话压缩成几句话的摘要,保留人名、项目、偏好等以后可能用到的事实:\n\n" + text}],
        extra_body=NO_THINKING,
    )
    return response.choices[0].message.content

Lors d'une troncature, la partie jetée est confiée à summarize, et le résumé obtenu est placé en tête de l'historique sous forme d'une paire de messages :

    summary = summarize(dropped)
    return [{"role": "user", "content": f"(之前对话的摘要:{summary})"},
            {"role": "assistant", "content": "好的,我记住了。"}] + keep

On relance avec le paramètre --summarize :

你:我叫小王,最近在用 httpx 写一个爬虫。
助手:好的小王,httpx 适合爬虫,支持异步和 HTTP/2。有具体问题可以问我。  (本轮输入 32 词元)
你:我叫什么名字?
助手:你叫小王。  (本轮输入 63 词元)
你:我在用什么库做什么?
助手:你在用 httpx 写爬虫。  (本轮输入 76 词元)
  [把最早的 4 条消息压缩成了摘要:小王在用 httpx 写爬虫;httpx 支持异步和 HTTP/2,适合爬虫。]
你:推荐一个解析 HTML 的库。
助手:推荐 BeautifulSoup 或 lxml,前者易用,后者更快。  (本轮输入 83 词元)
  [把最早的 4 条消息压缩成了摘要:小王在用 httpx 写爬虫;httpx 支持异步和 HTTP/2,适合爬虫。]
你:它和 httpx 怎么配合用?一句话。
助手:用 httpx 获取响应后,把 `resp.text` 传给 BeautifulSoup 解析即可。  (本轮输入 95 词元)
  [把最早的 4 条消息压缩成了摘要:小王在用 httpx 写爬虫,需要 HTML 解析库;推荐了 BeautifulSoup(易用)和 lxml(更快)。]
你:我叫什么名字?
助手:你叫小王。  (本轮输入 104 词元)
  [把最早的 4 条消息压缩成了摘要:小王在用 httpx 写爬虫,需 HTML 解析库;推荐了 BeautifulSoup(易用)和 lxml(更快)。与 httpx 配合时,把 `resp.text` 传给 BeautifulSoup 解析即可。]

À la dernière question, il se souvient. Le résumé a gardé les faits clés comme « 小王 » (Xiao Wang), « httpx » et « 爬虫 » (robot d'exploration). Les tokens d'entrée sont moins nombreux que sans troncature (104 contre 170), mais plus qu'avec une troncature simple, car le résumé occupe lui aussi de la place.

Le résumé a aussi un coût :

  • Chaque compression demande un appel de plus au modèle, ce qui coûte de l'argent et ralentit ce tour-là. On peut le faire en arrière-plan de façon asynchrone, ou attendre que l'historique atteigne une certaine longueur, plutôt que compresser à chaque tour.
  • Le résumé perd des détails. Il ne garde que ce que le modèle juge important. Si l'utilisateur mentionne en passant au tour 3 « mon serveur est à Hong Kong », le résumé l'omettra peut-être.
  • Le résumé peut se tromper. Le modèle peut mal résumer un fait.

En regardant bien la sortie ci-dessus, chaque compression re-condense « l'ancien résumé + les messages nouvellement jetés » : le résumé est sans cesse mis à jour en glissant.

Laquelle choisir

Situation Méthode
Les conversations sont généralement courtes, par exemple un service client Rien à faire, ou une troncature avec une limite large
Les conversations sont longues, mais seul le récent compte Troncature
Les conversations sont longues, et des informations anciennes resserviront Résumé, ou troncature plus résumé
Il faut retenir des informations sur de nombreuses sessions, par exemple les préférences de l'utilisateur Stocker à part les informations clés et les remettre dans le contexte au besoin : c'est la « mémoire à long terme » de la leçon 5 du module 05

Problèmes courants

Le message system a été mis dans history et tronqué avec : le message system doit être placé à part, en tête, à chaque fois, et ne jamais être tronqué. Le code ci-dessus le stocke séparément de history justement pour cela.

Les historiques de plusieurs utilisateurs se mélangent : dans un service web, chaque utilisateur et chaque session doivent avoir leur propre history, généralement stocké en base de données ou en cache et distingué par un identifiant de session. Une variable globale history ne convient qu'à un programme en ligne de commande pour une seule personne.

Exercices

  1. Lancez conversation.py, discutez plus de dix tours et observez l'évolution des tokens d'entrée. Recommencez avec --max-messages 6 et repérez le moment où il oublie quelque chose.
  2. Réécrivez trim pour tronquer selon le nombre de tokens plutôt que de messages : quand le total de l'historique dépasse 500 tokens, jeter les messages par paires en commençant par les plus anciens. Vous pouvez vous appuyer sur le usage.prompt_tokens renvoyé au tour précédent.
  3. Au tour 3, mentionnez en passant un détail (par exemple « mon serveur est à Hong Kong »), et demandez-le au tour 8. Essayez avec la troncature puis avec le résumé : le résumé a-t-il conservé ce détail ? Sinon, essayez de modifier le prompt de summarize.

Auto-test

1. Comment le modèle « se souvient »-il de ce que vous avez dit plus tôt ?

Le modèle lui-même ne se souvient de rien. À chaque appel, le programme lui envoie tous les messages user et assistant précédents avec la nouvelle question, et le modèle « lit » le contenu passé dans ces messages.

2. Pourquoi chaque tour d'une conversation coûte-t-il plus cher que le précédent ?

Chaque tour renvoie en entrée tout l'historique de la conversation ; plus l'historique est long, plus il y a de tokens d'entrée et plus c'est cher. La partie identique au début de l'historique peut être servie depuis le cache, bien moins chère, mais l'entrée totale continue de croître.

3. Quels sont les inconvénients respectifs de la troncature et du résumé ?

La troncature perd définitivement les informations anciennes, par exemple le nom de l'utilisateur, qu'il ne saura plus donner si on le lui redemande. Le résumé conserve les faits clés, mais chaque compression demande un appel de plus au modèle, perd les détails que le modèle juge secondaires, et peut mal résumer.

Questions et discussion

Bloqué sur cette leçon ? Posez votre question ici. Et si vous pouvez répondre à quelqu'un, n'hésitez pas.

Une question rapporte 3 points, une réponse 6. Les messages paraissent après vérification.

Chargement de la discussion…