Agent 多轮对话循环 —— 从单次问答到持续协作

5 阅读1分钟

前置阅读:一个最简化的 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()                       # 清空技能

切换时做了几件关键事情:

  1. 校验存在性 —— pm.load_session() 返回空 messages 则拒绝切换
  2. 恢复上下文摘要 —— 从持久化的 state["summary"] 中恢复,避免上下文过长
  3. 清空技能 —— 新会话可能有不同的技能需求,从零开始让 Agent 自己加载
  4. 后续调用自动恢复 —— 切换到新 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 直接处理 → 状态切换

这种设计的好处:

  1. 命令即使是 "切换到会话 abc" 这样的短语也不会被送给 LLM 误解析
  2. 命令执行零延迟(不走 API 调用)
  3. 未来可以轻松扩展,如 /export/settings/retry

8.2 session_id 是胶水

session_idchat_looprun_agent_with_trace 之间的唯一通信协议。它不包含任何业务数据,只是一个"钥匙"——拿着这把钥匙,Agent 循环自己去 PersistenceManager 里取对应的消息历史、上下文摘要和技能状态。

这种设计让 chat_loop 不需要理解"消息是什么"、"技能有哪些"——它只是一个无状态的编排器。

8.3 状态重置的粒度

/new/switch 都会重置 tracer 和 ctx,但策略不同:

/new/switch <id>
session_id新建切换到已有
tracer全新全新(旧 trace 保留在文件中)
ctx全新全新 + restore 目标会话的 summary
skillsreset()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 辅助编程到底层原理,帮你建立不可替代的技术壁垒。