Files
ai-agent-book/chapter6/async-agent/async_demos.py
T
liqiang b119135836
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
2026-08-20 13:12:50 +00:00

221 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""离线演示:不依赖任何 LLM / API key,直接驱动异步运行时的底层原语。
`demo.py` 里的四个「场景」需要真实 LLM 做决策;本模块则把实验 6-2 的三项核心
异步能力单独拎出来,用可测量、可复现的方式演示,**无需联网、无需 API key**
- demo_parallel :并行 vs 串行工具调用的【墙钟时间】对比(真实测量,打印加速比)。
- demo_interrupt :长任务运行中被【打断/取消】,随后系统【恢复】并接受新任务。
- demo_state Agent 状态【检查点持久化】到磁盘,再【跨会话恢复】并校验。
这三个演示共同回答「异步到底带来了什么」——用数字和状态变化说话,而不只是措辞。
"""
from __future__ import annotations
import asyncio
import datetime
import os
import time
from runtime import AgentRuntime, format_log
from events import Event, EventType
from tasks import TaskManager
import tasks
class Logger:
"""与 runtime 同款的彩色时间戳日志器(相对本次演示起点计时)。"""
def __init__(self) -> None:
self.t0 = time.time()
def __call__(self, source: str, text: str) -> None:
print(format_log(self.t0, source, text), flush=True)
def banner(title: str) -> None:
print("\n" + "=" * 78)
print(f" {title}")
print("=" * 78, flush=True)
# ============================ 1. 并行 vs 串行 ============================
# 一组相互独立的【只读感知工具】(读文件 / 搜索 / 查库 / 向量检索)。
# 只读、无副作用,因此可以安全地并行——这正是书中「感知工具天然适合并行」的落点。
_PERCEIVE_TOOLS = [
("read_config.json", 0.8),
("web_search(‘异步 Agent)", 1.2),
("db_query(orders)", 1.5),
("vector_lookup(memory)", 1.0),
]
async def _perceive(name: str, latency: float, log: Logger) -> tuple[str, float, float]:
"""模拟一次带 I/O 延迟的只读感知调用;返回 (名称, 标称延迟, 实测耗时)。"""
t0 = time.time()
log("TOOL", f"→ {name} 启动(模拟 I/O 耗时 {latency:.1f}s")
await asyncio.sleep(latency)
dt = time.time() - t0
log("TOOL", f"✓ {name} 完成(实测 {dt:.2f}s")
return name, latency, dt
async def demo_parallel() -> None:
banner("能力一|并行工具调用:并行 vs 串行的墙钟时间对比")
log = Logger()
log("SYSTEM", "有 4 个相互独立的只读感知工具需要调用(无副作用,可安全并行)。")
# —— 串行:一个 await 完再 await 下一个 ——
log("SYSTEM", "\033[0m[串行] 逐个 await(同步 ReAct 的默认做法)……")
seq_start = time.time()
for name, lat in _PERCEIVE_TOOLS:
await _perceive(name, lat, log)
seq_total = time.time() - seq_start
# —— 并行:一次性发起,asyncio.gather 并发等待 ——
log("SYSTEM", "\033[0m[并行] 一次性发起,asyncio.gather 并发等待……")
par_start = time.time()
await asyncio.gather(*[_perceive(name, lat, log) for name, lat in _PERCEIVE_TOOLS])
par_total = time.time() - par_start
slowest = max(lat for _, lat in _PERCEIVE_TOOLS)
speedup = seq_total / par_total if par_total else float("inf")
print("\n ── 结果对比 ─────────────────────────────────────────────")
print(f" {'工具':<26}{'标称延迟':>10}")
for name, lat in _PERCEIVE_TOOLS:
print(f" {name:<26}{lat:>8.1f}s")
print(" ─────────────────────────────────────────────────────────")
print(f" {'串行总耗时(Σ 各工具)':<26}{seq_total:>8.2f}s")
print(f" {'并行总耗时(gather':<26}{par_total:>8.2f}s")
print(f" {'并行理论下界(最慢单个)':<26}{slowest:>8.2f}s")
print(f" {'加速比 = 串行 / 并行':<26}{speedup:>8.2f}x")
print(" ─────────────────────────────────────────────────────────")
print(" 结论:独立的只读调用并行化后,墙钟时间由「求和」降到「取最大」。\n")
# ============================ 2. 打断 / 取消 / 恢复 ============================
async def demo_interrupt() -> None:
banner("能力二|打断与取消:长任务运行中被打断,随后系统恢复")
tasks.TICK_REAL = 0.15 # 本演示放慢节奏,留出「跑到一半再打断」的时间窗口
log = Logger()
completed: list = []
async def on_complete(state) -> None:
completed.append(state)
tm = TaskManager(on_complete=on_complete, log=log)
# 1) 并行启动三个后台异步任务
log("SYSTEM", "启动三个并行后台分析任务(fast/mid/slow)……")
for cmd in ["python analyze_fast.py", "python analyze_mid.py", "python analyze_slow.py"]:
tm.start(cmd)
# 2) 运行期间用户即时提问 —— 后台任务不被阻塞
await asyncio.sleep(1.0)
now = datetime.datetime.now().strftime("%H:%M:%S")
log("USER", "(即时提问)现在几点了?")
log("AGENT", f"现在 {now}。三个后台任务仍在并行推进,未被这次提问阻塞。")
# 3) 跑到中途,用户发出打断 —— 立即取消所有在跑的任务
await asyncio.sleep(1.0)
log("USER", "(打断)取消")
cancelled = tm.cancel_all()
await asyncio.sleep(0.05) # 让 CancelledError 在各协程内落地
log("SYSTEM", f"已执行打断:取消了 {cancelled}(进度在被取消处冻结)")
print("\n ── 打断后各任务状态(进度冻结在中途)───────────────────")
print(f" {'task_id':<8}{'命令':<26}{'状态':<12}{'进度':>6}")
for s in tm.all_states():
print(f" {s.task_id:<8}{s.command:<26}{s.status:<12}{s.progress:>5.0f}%")
print(" ─────────────────────────────────────────────────────────")
# 4) 恢复:executor 依然健康,接受并跑完一个新任务
log("SYSTEM", "打断处理完毕,系统恢复空闲,可继续接受新任务……")
fresh = tm.start("python re_run_summary.py")
await fresh._task
log("AGENT", f"已从打断中恢复,新任务 {fresh.task_id} 正常完成:"
f"{completed[-1].result[:36]}……")
print(" 结论:打断只冻结被取消的任务,运行时本身无损,可立即继续工作。\n")
# ============================ 3. 状态检查点:持久化 / 恢复 ============================
def _seed_trajectory(rt: AgentRuntime) -> None:
"""给运行时灌入一段「已发生」的对话轨迹,模拟会话进行到一半。"""
rt._append(Event(EventType.USER_INPUT,
message={"role": "user", "content": "分析今天的日志并总结异常"},
label="用户消息:分析日志"))
rt._append(Event(EventType.AGENT_TOOL_CALL,
message={"role": "assistant", "content": "好的,我这就在后台启动分析。",
"tool_calls": [{"id": "call_1", "type": "function",
"function": {"name": "run_terminal_command",
"arguments": '{"command": "python analyze_fast.py"}'}}]},
label="调用工具 run_terminal_command"))
rt._append(Event(EventType.TOOL_RESULT,
message={"role": "tool", "tool_call_id": "call_1",
"content": "命令已在后台异步启动。task_id=T1。"},
label="工具结果 run_terminal_command"))
async def demo_state() -> None:
banner("能力三|状态管理:检查点持久化与跨会话恢复")
tasks.TICK_REAL = 0.15
ckpt_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "checkpoints")
os.makedirs(ckpt_dir, exist_ok=True)
path = os.path.join(ckpt_dir, "agent_state.json")
# —— 会话 A:产生一段轨迹 + 两个仍在运行的后台任务,然后落盘 ——
log = Logger()
log("SYSTEM", "会话 A 开始:构造轨迹并启动两个后台任务……")
rt_a = AgentRuntime(client=None, model="demo-offline")
rt_a._t0 = log.t0 # 让两个运行时共用同一时间基准,便于观察
_seed_trajectory(rt_a)
rt_a.tasks.start("python analyze_fast.py") # 进行中
rt_a.tasks.start("python analyze_slow.py") # 进行中
await asyncio.sleep(1.2) # 让进度累积到中途
before_traj = len(rt_a.trajectory)
before_tasks = {s.task_id: (s.status, s.progress) for s in rt_a.tasks.all_states()}
rt_a.save_checkpoint(path)
# 模拟进程退出:取消掉活着的协程
rt_a.tasks.cancel_all()
await asyncio.sleep(0.05)
log("SYSTEM", "会话 A 结束(进程退出,内存中的运行时已销毁)。")
# —— 会话 B:全新运行时,从磁盘恢复 ——
log("SYSTEM", "会话 B 开始:新建空运行时,从检查点恢复……")
rt_b = AgentRuntime(client=None, model="demo-offline")
rt_b._t0 = log.t0
data = rt_b.load_checkpoint(path)
after_traj = len(rt_b.trajectory)
msgs = rt_b.build_messages() # 证明恢复后能重建可喂给 LLM 的上下文
print("\n ── 恢复校验 ─────────────────────────────────────────────")
print(f" 轨迹事件数 保存前 {before_traj} -> 恢复后 {after_traj} "
f"[{'一致 ✓' if before_traj == after_traj else '不一致 ✗'}]")
print(f" 可重建 LLM 上下文消息 {len(msgs)} 条(system + 轨迹回放)")
print(f" {'task_id':<8}{'命令':<26}{'保存前进度':>10} {'恢复后状态':<12}{'进度':>6}")
for rec in data["tasks"]:
tid = rec["task_id"]
before = before_tasks.get(tid, ("-", 0.0))
st = rt_b.tasks.query(tid)
print(f" {tid:<8}{rec['command']:<26}{before[1]:>9.0f}% "
f"{st.status:<12}{st.progress:>5.0f}%")
print(" ─────────────────────────────────────────────────────────")
print(f" 检查点文件:{path}")
print(" 结论:轨迹与任务进度完整落盘并跨会话还原;运行中的任务被标记为 suspended")
print(" 保留了最后已知进度,供上层决定「重跑」还是「按进度续跑」。\n")
# 供 demo.py 复用的离线演示注册表
OFFLINE_DEMOS = {
"parallel": demo_parallel,
"interrupt": demo_interrupt,
"state": demo_state,
}