前置阅读:一个最简化的 AI Agent 核心实现 | Agent Skill 渐进式加载 核心增量:
chat_loop()(~120 行)+PersistenceManager.list_sessions()+SkillManager.reset()
一、问题:单次问答够用吗?
前两篇文章构建的 Agent 有两个核心能力:ReAct 循环(思考→行动→观察)和渐进式 Skill 加载(按需激活工具)。但它们都有一个共同的局限——每次问答都是一次性的。
用户: "北京天气怎么样?"
Agent: "北京今天晴,25°C"
← 上下文丢失,下次得从头来
用户: "那上海呢?"
Agent: "你说的是哪个上海?" ← 不记得刚才在讨论天气
真实对话不是一问一答的片段,而是持续的、有上下文的协作。本文要解决的就是这个 gap:如何在已有 Agent 核心的基础上,用约 120 行代码实现一个生产可用的交互式多轮对话循环。
二、核心结构:chat_loop 三条线
chat_loop 虽然只有 ~120 行,但负责三条职责线的编排:
chat_loop()
│
├── 交互线:input() 循环 + 输出
│ ├─ 读入用户输入
│ ├─ 调用 run_agent_with_trace()
│ └─ 打印 Agent 回复
│
├── 命令线:/exit /history /switch /new /help
│ ├─ 会话切换 → 恢复/创建 session
│ ├─ 历史浏览 → list_sessions()
│ └─ 新建会话 → 重置 tracer + ctx + skills
│
└── 状态线:tracer + ctx + pm + skills
├─ 默认会话 → 自动创建 session_id
├─ 正常对话 → 每轮带 session_id 调用 Agent
└─ 会话结束 → 持久化保存
三条线的交点是 session_id——它是所有状态(对话历史、上下文压缩、技能激活)的"身份证"。
三、主循环:一个 while True 就够了
def chat_loop(client, skills, base_system_prompt, model, session_id=None, store_dir="./agent_sessions"):
tracer = AgentTracer()
ctx = ContextManager()
pm = PersistenceManager(Store(store_dir))
if session_id is None:
session_id = pm.new_session_id()
while True:
try:
user_input = input("You: ").strip()
except (EOFError, KeyboardInterrupt):
print("\n👋 再见")
break
if not user_input:
continue
# ── 命令解析 ──
if user_input == "/exit":
break
elif user_input == "/history":
_show_history(pm, session_id)
elif user_input.startswith("/switch"):
_switch_session(user_input, pm, session_id, tracer, ctx, skills)
elif user_input == "/new":
session_id, tracer, ctx = _new_session(pm, skills)
elif user_input == "/help":
_show_help()
# ── 正常对话 ──
else:
answer = run_agent_with_trace(
user_input,
tracer=tracer, client=client, ctx=ctx, pm=pm,
skills=skills, base_system_prompt=base_system_prompt,
session_id=session_id, model=model,
)
print(f"Agent: {answer}\n")
设计要点
| 要点 | 说明 |
|---|---|
session_id 贯穿所有调用 | 每次 run_agent_with_trace 都传入同一个 session_id,对话历史自动累积 |
| 命令短路径 | /exit、/help 等命令直接 return,不走 Agent 循环,零 token 消耗 |
| 异常安全 | EOFError/KeyboardInterrupt 捕获,Ctrl+C 直接退出,不丢持久化数据 |
| 空输入跳过 | if not user_input: continue,防止空白行触发 LLM 调用 |
四、会话管理三件套:新建 / 列表 / 切换
4.1 新建会话
elif user_input == "/new":
session_id = pm.new_session_id() # 新 ID
tracer = AgentTracer() # 清空 trace
ctx = ContextManager() # 清空上下文摘要
skills.reset() # 卸载所有激活的 Skill
/new 不是简单的"清空对话",而是完整的状态重置——tracer、ctx、skills 全部回到初始状态。这避免了上一个会话的摘要污染新会话,也防止了之前激活的 Skill 在新会话中"幽灵般"地可用。
4.2 历史会话列表
elif user_input == "/history":
sessions = pm.list_sessions() # PersistenceManager 新增方法
for s in sessions:
marker = " ← 当前" if s["id"] == session_id else ""
print(f" {s['id']} | {s['message_count']}条 | {s['updated']} | {s['last_message']}{marker}")
list_sessions() 遍历 store_dir 下的所有会话目录,读取每个会话的元信息(消息数、更新时间、最后一条消息预览),按更新时间倒序排列。当前会话用 ← 当前 标记。
4.3 会话切换
elif user_input.startswith("/switch"):
new_id = user_input.split(" ", 1)[1].strip()
state = pm.load_session(new_id) # 加载目标会话的完整状态
if not state["messages"]:
print(f"❌ 会话 {new_id} 不存在或为空")
continue
session_id = new_id
tracer = AgentTracer() # 清空当前 trace
ctx = ContextManager() # 清空上下文摘要
ctx.restore(state["summary"]) # 恢复目标会话的摘要
skills.reset() # 清空技能
切换时做了几件关键事情:
- 校验存在性 ——
pm.load_session()返回空 messages 则拒绝切换 - 恢复上下文摘要 —— 从持久化的
state["summary"]中恢复,避免上下文过长 - 清空技能 —— 新会话可能有不同的技能需求,从零开始让 Agent 自己加载
- 后续调用自动恢复 —— 切换到新 ID 后,下一句对话的
run_agent_with_trace会从state["messages"]和state["active_skills"]中恢复完整状态
五、与 run_agent_with_trace 的协作
chat_loop 自己不处理 Agent 逻辑,它只负责"什么时候调用、传什么参数"。真正的对话能力仍然来自 run_agent_with_trace,关键参数是 session_id:
# 第一次调用 → session_id 不存在 → 创建新会话
# 第二次调用 → session_id 存在 → 加载历史 + 追加新消息
# 第三次调用 → session_id 存在 → 对话越来越长(ctx.maybe_compact 自动压缩)
answer = run_agent_with_trace(
user_input,
session_id=session_id, # ← 同一个 ID,对话自动延续
...
)
每次调用内部发生的事情:
run_agent_with_trace("那上海呢?", session_id="xxx")
│
├── pm.load_session("xxx")
│ ├─ 恢复 messages(包含之前的"北京天气怎么样?")
│ ├─ 恢复 ctx 摘要
│ └─ 恢复 active_skills(如之前加载了 search)
│
├── 追加 {"role": "user", "content": "那上海呢?"}
│
├── ReAct 循环:
│ Round 1: LLM 已知在讨论天气 → search_web("上海天气")
│ Round 2: LLM 得到结果 → 输出 "上海今天小雨,22°C"
│
└── pm.save_session("xxx", messages, summary, active_skills)
六、三条线的完整时序
用户输入 → chat_loop 分发
│
├─ /exit → break(退出循环)
│
├─ /help → 打印帮助,continue(不调 Agent)
│
├─ /new → 重置 tracer/ctx/skills + 新 session_id,continue
│
├─ /history → list_sessions() → 打印列表,continue
│
├─ /switch <id> → load_session → 恢复状态 → 切换 ID,continue
│
└─ 正常文本 → run_agent_with_trace(user_input, session_id=session_id, ...)
│
├── 首次调用:创建会话 → ReAct 循环 → 持久化
└── 后续调用:加载会话 → 追加消息 → ReAct 循环 → 持久化
注意所有命令(/xxx)都不经过 Agent 循环——它们由 chat_loop 直接处理,零 LLM 调用、零 token 消耗。
七、与前三层的集成全貌
flowchart TB
subgraph L1[Layer 4 - 交互层]
CHAT[chat_loop - REPL + 命令系统 + 会话管理]
end
subgraph L2[Layer 3 - 循环层]
RUNT[run_agent_with_trace]
subgraph INTERNALS[每轮动态组装]
SK[skills 渐进加载]
TR[tracer 可观测]
CTX[ctx 上下文压缩]
end
end
subgraph L3[Layer 1-2 - 基础设施]
SM[SkillManager]
AT[AgentTracer]
CM[ContextManager]
end
subgraph L4[持久化]
PM[PersistenceManager - save, load, list]
end
CHAT -- 每次对话调用 --> RUNT
RUNT --> SK
RUNT --> TR
RUNT --> CTX
SK --> SM
TR --> AT
CTX --> CM
SM --> PM
CM --> PM
AT -. 日志写入 .-> PM
八、设计精要
8.1 命令是"带外信道"
/exit、/history、/switch 等命令不经过 LLM,由 chat_loop 直接处理。这是一种带外控制的设计——用户和 Agent 之间的交互分为两条独立的信道:
- 带内信道:自然语言 → LLM → ReAct 循环 → 工具调用
- 带外信道:
/前缀命令 → chat_loop 直接处理 → 状态切换
这种设计的好处:
- 命令即使是
"切换到会话 abc"这样的短语也不会被送给 LLM 误解析 - 命令执行零延迟(不走 API 调用)
- 未来可以轻松扩展,如
/export、/settings、/retry
8.2 session_id 是胶水
session_id 是 chat_loop 与 run_agent_with_trace 之间的唯一通信协议。它不包含任何业务数据,只是一个"钥匙"——拿着这把钥匙,Agent 循环自己去 PersistenceManager 里取对应的消息历史、上下文摘要和技能状态。
这种设计让 chat_loop 不需要理解"消息是什么"、"技能有哪些"——它只是一个无状态的编排器。
8.3 状态重置的粒度
/new 和 /switch 都会重置 tracer 和 ctx,但策略不同:
/new | /switch <id> | |
|---|---|---|
| session_id | 新建 | 切换到已有 |
| tracer | 全新 | 全新(旧 trace 保留在文件中) |
| ctx | 全新 | 全新 + restore 目标会话的 summary |
| skills | reset() | reset()(让 Agent 自己按需加载) |
| messages | 空 | 从目标会话恢复 |
8.4 为什么不用类封装
当前实现中 chat_loop 是一个函数,tracer/ctx/pm 都是局部变量。没有用类封装 ChatSession 对象的原因很简单:不需要。所有状态的持久化和恢复都由 PersistenceManager 负责,chat_loop 自己没有任何需要跨函数共享的可变状态。保持为纯函数:
- 参数进来 → 调用 Agent → 打印结果 → 参数出去
- 中间状态都在
tracer/ctx/pm这些已有对象中 - 如果未来需要更复杂的生命周期管理,再考虑提取为类——但在那之前,一个函数够了
九、完整演进路径
Phase 1: 基础 Agent (~50 行)
run_agent() → 单次问答
工具:search + calculate
Phase 2: 可观测 + 压缩 + 持久化 (+~200 行)
run_agent_with_trace() → 单次问答带 trace
AgentTracer + ContextManager + PersistenceManager
Phase 3: Skill 渐进加载 (+~150 行)
SkillManager → 按需激活工具
load_skill / unload_skill 元工具
Phase 4: 多轮对话循环 (+~120 行) ← 本文
chat_loop() → 交互式 REPL
命令系统 + 会话切换 + 历史浏览
每一步在前一步的基础上叠加,互不侵入。第四层 chat_loop 完全不需要修改前三层的任何代码——它只是换了一种调用方式:从一次性调用变成循环调用,从单会话变成多会话管理。
推荐学习资源
- 从零开始打造一个AI Agent CLI — 手把手带你构建一个完整的 AI Agent 命令行工具,从工具调用、对话管理到发布上线的全流程实战。
- AI Agents 开发实践 — 涵盖 Agent 架构设计、记忆系统、多 Agent 协作等核心主题的实战教程,适合想深入 Agent 开发的工程师。
- AI 全栈编程生存指南 — AI 时代的全栈开发者生存法则,从 AI 辅助编程到底层原理,帮你建立不可替代的技术壁垒。