系列:AgentScope 2.0 学习笔记 · 第 002 篇
难度:入门进阶
适合谁:想做客服/导购/知识问答类 Agent 的开发者,以及被"失忆机器人"困扰的人
前置:建议先读 001《Agent 编排初体验》
阅读收获:搞懂 Agent 的多轮对话与「记忆」机制,学会按会话隔离实例
运行环境:WSL Ubuntu-24.04 + Python 3.11+ + AgentScope 2.x


你有没有遇到过这种场景:给客户演示聊天机器人,前一句刚说完"这款适合干皮",下一句对方追问"那这个多少钱",它却像失忆了一样,答得牛头不对马嘴。

这不是模型不行,是多轮对话的「记忆」没接上。真实场景里,没人会只问一句就走——尤其是客服、导购、售后这类场景,用户天然是连续追问的:“多少钱?”“有优惠吗?”"适合敏感肌吗?"每一句都踩在上一句的肩膀上。Agent 要是每轮都失忆,用户就得从头解释一遍,体验直接崩盘。

这篇就来解决这件事:怎么让 Agent 在多轮对话里记住前面说过的话。 而且结论可能和你想的不一样——不是要你写一堆记忆代码,真相简单得多。


一、上一篇留下的悬念

001 篇我们跑通了第一个 Agent,但留下一个没解决的问题:agent.reply() 每次都是「一问一答」,答完就散伙。

真实场景里没人只问一句就走。用户会连续追问——「多少钱?」「有优惠吗?」「适合敏感肌吗?」——每一句都建立在上一句之上。如果 Agent 每轮都失忆,用户就得从头解释一遍,体验极差。

所以这一篇要解决的是:怎么让 Agent 在多轮对话里「记住」前面说过的话。


二、一个反直觉的发现:Agent 其实自带记忆

先抛结论,这个结论是我实测出来的,可能会颠覆你的直觉:

AgentScope 2.x 的 Agent 实例,本身就自带记忆。你什么都不用做,它就「记住」了前文。

证据在哪?看我在 Day 002 实测的一组数据——同一个 Agent 实例连续三轮对话,每轮的输入 Token(in)是这样:

轮次用户说的输入 Token(in)
第 1 轮你好,我的皮肤偏干,用什么好?132
第 2 轮那这个保湿霜多少钱?211
第 3 轮还需要搭配什么一起用吗?294

注意 in-token 在逐轮递增:132 → 211 → 294。

如果 Agent 每轮只看到当前这一句话,in-token 应该稳定在 130 左右。它逐轮变大,只有一个解释:框架把前面的对话历史也一起喂给了模型。 也就是说,Agent 对象内部维护了一个「记忆」,同一实例连续 reply() 时,自动把历史带上。

这就是为什么第 2 轮用户只说「那这个」,Agent 也能知道「这个」指的是上一轮推荐的那款产品——不是它猜的,是它真的「记得」。


三、多轮对话,代码怎么写

既然 Agent 自带记忆,多轮对话的代码就简单到只剩一句话:循环 + 复用同一个实例

关键不是「循环」,而是「复用」。循环只是让程序能连续读输入;真正让 Agent「记住」的,是自始至终用同一个 agent 对象,而不是每轮 Agent(...) 新建一个。

所以正确写法是:Agent 在循环外面建一次,循环里面只负责 await agent.reply(msg)

(完整代码见第七节)


四、怎么「看见」记忆

记忆看不见摸不着,怎么证明它真的在?两个办法,本文代码两个都用上了:

办法一:看 in-token。 逐轮打印每轮的输入 Token,只要它逐轮递增,就是「历史被带上」的铁证。这是最硬核、最不容易自欺欺人的验证方式。

办法二:直接问它。 你先说「我叫三叔」,下一轮问「我叫什么名字」,它能答对「三叔」,就说明记住了。这种「显式回溯」最直观,一眼能看懂。

后面第七节的代码里,demo_multi_turn() 三段对话就埋了这个陷阱:第二句用「那」指代、第三句直接回溯「我刚才说我想学什么」,专门用来检验记忆。


五、记忆的边界:一个会话一个实例

记忆有边界,这恰恰是新手最容易踩的坑:

  • 复用同一个实例 = 连续记忆,上下文一直累积;
  • 新建一个实例 = 全新记忆,之前说的全忘。

代码里的 demo_isolation() 用两组对照把这件事摆到台面上:同一个「我叫三叔」的信息,复用实例能答对,新建实例一脸懵。

这件事在生产环境是一条铁律:一个用户会话,对应一个 Agent 实例。 绝不能全局共享一个 Agent——那样 A 用户说的话会被 B 用户看到(串话),或者服务重启后所有人集体失忆。

这跟 001 篇强调的「全局状态是 bug 温床」是同一件事:Agent 实例就是「状态」,状态必须按会话隔离。

顺带说一个隐藏问题:记忆不是免费的。 历史累积得越多,每轮的 in-token 就越大,费用越高,而且迟早撞上模型的「上下文窗口」上限——也就是模型一次能处理的最大 Token 数。到那时,要么丢弃最旧的对话,要么压缩成摘要。怎么在「记住」和「省 Token」之间平衡,是后续「上下文管理」的课题。这里你只要先记住一个概念:记忆有成本


六、运行环境(同 001,保持一致)

  • 系统:WSL Ubuntu-24.04
  • Python:3.11+,虚拟环境 venv
  • 框架:AgentScope 2.x
  • API Key:DeepSeek / 混元 / 百炼,环境变量注入
# 1. 进 WSL 激活环境
wsl -d Ubuntu-24.04
cd /2026_Study/agentscope/
source venv/bin/activate

# 2. 设置三把 Key(sk-xxxx 换成你自己的真实 Key)
export DEEPSEEK_API_KEY="sk-xxxx"
export HUNYUAN_API_KEY="sk-xxxx"
export BAILIAN_API_KEY="sk-xxxx"

# 3. 运行(本文件是预设对话,自动跑,无需手动输入)
python 002_multi_turn.py

七、完整代码(单文件自包含)

保存为 002_multi_turn.py,单独运行。核心就是两个演示:多轮对话 + 记忆边界

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
AgentScope 2.0 · 多轮对话,让 Agent 记住你
==============================================
演示1:连续多轮对话,用 in-token 递增「看见」记忆;
演示2:复用实例 vs 新建实例,验证记忆的边界。
运行环境:WSL Ubuntu-24.04 + Python 3.11+ + AgentScope 2.x
运行命令:python 002_multi_turn.py
"""

import os
import asyncio
import gc
import time

# ============ 导入 AgentScope 核心零件 ============
from agentscope.credential import (
    DeepSeekCredential,
    OpenAICredential,
    DashScopeCredential,
)
from agentscope.model import (
    DeepSeekChatModel,
    OpenAIChatModel,
    DashScopeChatModel,
)
from agentscope.agent import Agent
from agentscope.message import Msg


# 「小秘」人设
SYSTEM_PROMPT = (
    "你叫「小秘」,是一位亲切、耐心的技术学习助手。"
    "回答简洁、清晰、有温度。"
)


def build_models():
    """三个模型,统一 stream=False —— 这是「干净退出」的第一件套。"""
    deepseek = DeepSeekChatModel(
        credential=DeepSeekCredential(api_key=os.environ["DEEPSEEK_API_KEY"]),
        model="deepseek-v4-flash",
        stream=False,  # 关闭流式,避免流式响应残留导致退出报错
    )
    hunyuan = OpenAIChatModel(
        credential=OpenAICredential(
            api_key=os.environ["HUNYUAN_API_KEY"],
            base_url="https://tokenhub.tencentmaas.com/v1",
        ),
        model="hy3",
        stream=False,
    )
    bailian = DashScopeChatModel(
        credential=DashScopeCredential(api_key=os.environ["BAILIAN_API_KEY"]),
        model="qwen-max",
        stream=False,
    )
    return {"DeepSeek": deepseek, "混元": hunyuan, "百炼": bailian}


def extract_text(response):
    """从回复里取出纯文本(AgentScope 回复是分块结构)。"""
    for block in response.content:
        if getattr(block, "type", None) == "text":
            return block.text
    return ""


def extract_usage(response):
    """提取 Token 用量,返回 (input_tokens, output_tokens)。"""
    usage = getattr(response, "usage", None)
    if usage is None:
        return ("?", "?")
    return (getattr(usage, "input_tokens", "?"), getattr(usage, "output_tokens", "?"))


async def close_models(models):
    """干净退出的第二件套:逐个 await self.client.close()。

    三个模型底层都是 openai.AsyncClient(存在 self.client 属性里),
    close() 是异步方法,必须 await 才会真正关闭连接池。
    """
    closed = 0
    for m in models.values():
        client = getattr(m, "client", None)
        if client is None:
            continue
        try:
            await client.close()
            closed += 1
        except Exception:
            pass
    if closed:
        print(f"\n🧹 已关闭 {closed} 个 HTTP 连接池")


async def demo_multi_turn(model, model_name):
    """演示1:连续多轮对话。重点观察每轮 in-token 是否递增(=框架自动带历史)。"""
    agent = Agent(name="小秘", model=model, system_prompt=SYSTEM_PROMPT)

    # 预设三段对话:第二句用「那」指代上一句,第三句显式回溯,考验记忆
    conversation = [
        "你好,我叫三叔,我想学 AgentScope,第一步该做什么?",
        "好的,那我需要先装 Python 环境吗?",   # 「那」指代上一轮的「第一步」
        "我刚才说我想学什么?",                 # 显式回溯,考验记忆
    ]

    total_in = 0
    total_out = 0

    print(f"\n{'=' * 56}\n  演示1:多轮对话(模型:{model_name})\n{'=' * 56}")
    for i, q in enumerate(conversation, 1):
        t0 = time.perf_counter()
        response = await agent.reply(
            Msg(name="user", content=[{"type": "text", "text": q}], role="user")
        )
        elapsed = time.perf_counter() - t0

        text = extract_text(response)
        in_tok, out_tok = extract_usage(response)
        if isinstance(in_tok, int):
            total_in += in_tok
        if isinstance(out_tok, int):
            total_out += out_tok

        print(f"\n你: {q}")
        print(f"小秘: {text}")
        print(f"  [第{i}轮 | {elapsed:.2f}s | 本轮 in={in_tok} out={out_tok} "
              f"| 累计 in={total_in} out={total_out}]")

    print("\n  💡 观察:本轮 in 值是否逐轮递增?递增 = 框架把历史也喂给了模型")


async def demo_isolation(model, model_name):
    """演示2:记忆的边界 —— 复用实例会记住,新建实例会失忆。"""
    print(f"\n{'=' * 56}\n  演示2:记忆边界(模型:{model_name})\n{'=' * 56}")

    # 场景 A:复用同一个实例(应该记住)
    agent_a = Agent(name="小秘A", model=model, system_prompt=SYSTEM_PROMPT)
    await agent_a.reply(
        Msg(name="user", content=[{"type": "text", "text": "记住:我叫三叔。"}], role="user")
    )
    resp = await agent_a.reply(
        Msg(name="user", content=[{"type": "text", "text": "我叫什么名字?"}], role="user")
    )
    print(f"\n  [复用实例] 问「我叫什么名字?」→ 小秘:{extract_text(resp)[:60]}")

    # 场景 B:新建一个实例(应该不记得)
    agent_b = Agent(name="小秘B", model=model, system_prompt=SYSTEM_PROMPT)
    resp = await agent_b.reply(
        Msg(name="user", content=[{"type": "text", "text": "我叫什么名字?"}], role="user")
    )
    print(f"  [新建实例] 问「我叫什么名字?」→ 小秘:{extract_text(resp)[:60]}")

    print("\n  💡 结论:复用实例 = 连续记忆;新建实例 = 全新记忆(一个会话一个实例)")


def check_keys():
    """开工前确认三把 Key 都在环境变量里。"""
    need = ["DEEPSEEK_API_KEY", "HUNYUAN_API_KEY", "BAILIAN_API_KEY"]
    missing = [k for k in need if not os.environ.get(k)]
    if missing:
        print("❌ 缺少环境变量:" + ", ".join(missing))
        print("   请先 export 对应的 API Key")
        return False
    return True


async def main():
    if not check_keys():
        return

    models = build_models()

    try:
        await demo_multi_turn(models["DeepSeek"], "DeepSeek (deepseek-v4-flash)")
        await demo_isolation(models["DeepSeek"], "DeepSeek (deepseek-v4-flash)")
    finally:
        # 干净退出三件套:关连接池 + 断引用 + 强制 GC
        await close_models(models)
        del models
        gc.collect()


if __name__ == "__main__":
    asyncio.run(main())

八、代码拆解:三个要点

① 复用实例是关键。 demo_multi_turn() 里,agent = Agent(...) 只在循环外建一次,循环里反复 await agent.reply(...)。这正是「记忆」的来源——如果你把 Agent(...) 放进循环里每轮新建,记忆就丢了。

② in-token 是「记忆照妖镜」。 每轮打印 in= 值,你能直接看到历史被喂进去的痕迹。这是比「看回答像不像人话」更硬的证据——回答可以糊弄你,Token 数不会。

perf_counter() 计时。time.perf_counter() 而不是 time.time(),因为前者是单调时钟,不受系统时间调整影响,测耗时更准。这个细节在 001 篇的笔记里也强调过。


九、运行结果(真实输出)

下面是本文件在 WSL Ubuntu-24.04 里的真实运行结果(模型有随机性,你的输出措辞会略有不同,属正常现象)。

演示 1(多轮对话) —— 注意每轮的 in 值,它逐轮递增,这就是「记忆」的硬证据:

你: 你好,我叫三叔,我想学 AgentScope,第一步该做什么?
小秘: 三叔您好!很高兴带您入门 AgentScope 😊
     第一步很简单:装环境,跑通官方 Hello World。...
  [第1轮 | 3.01s | 本轮 in=115 out=171 | 累计 in=115 out=171]

你: 好的,那我需要先装 Python 环境吗?
小秘: 需要,而且这是最关键的一步 ✅ ...(顺着上一轮的「第一步」展开)
  [第2轮 | 2.62s | 本轮 in=301 out=251 | 累计 in=416 out=422]

你: 我刚才说我想学什么?
小秘: 您刚才说的是想学 AgentScope 😄 这是蚂蚁集团开源的智能体开发框架...
  [第3轮 | 1.79s | 本轮 in=562 out=76 | 累计 in=978 out=498]

把三轮的 in 值单独拎出来:115 → 301 → 562。如果 Agent 每轮只看当前这一句,in 应该稳定在 115 左右;它翻了近五倍,唯一的解释就是框架把前几轮的历史也一起喂给了模型。

更关键的是第三句——用户问「我刚才说我想学什么」,小秘准确答出「AgentScope」,这就是显式记忆回溯成功了。

演示 2(记忆边界) —— 同一个「我叫三叔」的信息,复用实例和新建实例的表现截然相反:

[复用实例] 问「我叫什么名字?」→ 小秘:您刚才告诉我了,您叫三叔呀!我记着呢。
[新建实例] 问「我叫什么名字?」→ 小秘:嗨!你还没告诉我你的名字呢~方便的话告诉我...

复用实例答对了「三叔」,新建实例表示「还不知道你的名字」——完美印证了「一个会话一个实例」的铁律。

最后一行 🧹 已关闭 3 个 HTTP 连接池,程序干净退出、零收尾报错。


十、一个延续的坑:干净退出三件套

001 篇结尾我埋了个伏笔,说那个「很唬人的收尾报错」下一篇细讲,现在兑现。

AgentScope 2.x 的模型退出时,可能抛 generator didn't stop after athrow()。这个坑藏了三个雷,缺一不可:

  1. 找错对象:三个模型底层都是 openai.AsyncClient(存在 self.client 属性里),不是 httpx.AsyncClient。关错对象等于没关。
  2. awaitclose() 是异步方法,不 await 等于没关。
  3. 流式残留:模型默认 stream=True,流式响应没被完整消费就残留一个生成器,循环关闭后才被回收就报错。

对应的「干净退出三件套」就是代码里这三行思路:三模型统一 stream=False(根治流残留)+ 逐 await self.client.close()(关对对象、带 await)+ del models / gc.collect()(断引用兜底)。这个经验后续所有多模型示例都会沿用。


十一、总结与下一步

这一篇,你解决了 Agent 从「答一句」到「聊一场」的跃迁:

单轮(001)→ 多轮循环(本篇)→ 复用实例 = 连续记忆 → 一个会话一个实例

更关键的是,你学会了用 in-token 递增这个硬指标去「看见」记忆,而不是靠感觉——这是从「会用」走向「会验证」的分水岭。

下一篇《AgentScope 2.0 学习笔记:给 Agent 一张「人设卡」》,我们把 system_prompt 从一句问候升级成完整的人设卡:角色、口吻、红线、回答流程,让同一个模型摇身变成专业的「小帮」导购。敬请期待。


十二、写在最后:失忆的问题,不只是代码问题

这篇讲的是「让 Agent 记住」,但做过真实项目的人都知道:记忆的难点从来不在代码,而在设计——什么时候该记住、什么时候该忘、记多少才不会把 Token 烧穿。同一个问题,在单机演示和并发生产环境里,答案是两套。

就拿「一个用户会话对应一个 Agent 实例」这条铁律来说:并发量上来之后,实例怎么管理、闲置的怎么回收、长会话的上下文超了怎么办——每一个都是要在真实业务里对着数据调出来的。

如果你正在做一个客服、导购或知识问答类的 Agent,欢迎来评论区讨论,聊聊你的场景卡在哪。这类问题光靠教程补不全,得对着具体业务一步步抠。下一篇「人设卡」见。


本文为「AgentScope 2.0 学习笔记」系列第 002 篇,代码已通过 py_compile 语法校验,运行环境见第六节。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐