系列: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()。这个坑藏了三个雷,缺一不可:
- 找错对象:三个模型底层都是
openai.AsyncClient(存在self.client属性里),不是httpx.AsyncClient。关错对象等于没关。 - 漏
await:close()是异步方法,不await等于没关。 - 流式残留:模型默认
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 语法校验,运行环境见第六节。