Agent 单体与 chat 主循环
30 秒导读: 这一章只讲一件事——PraisonAI 里一个
Agent收到一句 prompt 后,自己这一轮是怎么转的: 怎么把角色、记忆、工具拼成 system prompt,怎么把预算算清楚不撑爆上下文,怎么发起 LLM 调用、 让模型决定是否调工具,再把结果拼回去继续问,直到模型给出最终答复。多个 Agent 之间怎么协作、 工具内部怎么安全执行、LLM provider 怎么分派——都不在本章,本章只讲一个 Agent 自己的一轮。
相关章节:总览见 index;工具的定义/注册/安全执行见 02-tools-and-mcp; LLM 层的双路径、多 provider 与容错见 03-llm-layer;多 Agent 编排见 04-multi-agent-orchestration。
1. 这是什么(零基础也能懂)
一句话定义: Agent 是 PraisonAI 的最小可用单体——你给它一个角色和一句话,它就能自己去想、
去调工具、再把工具结果读回来接着想,最后回你一段答复。
它解决什么问题: 裸调 LLM 只能"一问一答":你问,它答,完了。但真实任务往往是"你问 → 它得先查天气 →
拿到天气再算 → 才能答"。这中间"调工具、把工具结果喂回模型、再让模型接着说"的多轮编织,得有人来做。
Agent 就是做这件事的人。
用起来什么样: 最小的一次真实调用长这样。
from praisonaiagents import Agent
agent = Agent(instructions="你是一个乐于助人的助手")
answer = agent.chat("用一句话解释什么是黑洞") # 同步、阻塞,返回字符串
print(answer)
带工具时也只是多传一个 tools=[...],chat 内部会自动完成"模型要求调工具 → 执行 → 回填 → 再问"这一套:
def get_weather(city: str) -> str: # 普通 Python 函数就是工具
return f"{city} 今天晴,26℃"
agent = Agent(instructions="你是天气助手", tools=[get_weather])
print(agent.chat("北京今天适合出门吗?")) # 模型会先调 get_weather,再据此回答
一句话直觉: 把 Agent 想成一个会用工具的接线员。你说一句话,它先在脑子里(system prompt)记好
"我是谁、我手上有哪些工具、我记得关于你的什么",然后开始打电话(调 LLM);电话那头(模型)如果说
"我需要先查一下 X",接线员就去查(执行工具),把查到的结果再念回电话里,直到那头说"好了,答案是……"。
本节到此不碰任何底层代码。下面开始拆它怎么转。
2. 顶层全景(它大概怎么转)
2.1 一轮 chat 的主干流水
先看一整轮从 agent.chat("...") 到拿到字符串,数据都流经哪些环节。怎么读这张图:从上到下是时间顺序,
右侧标注了真实方法名与所在行。
agent.chat(prompt) chat_mixin.py:1910
│
┌───────────────┼───────────────┐
│ ① 预处理:slash 技能展开 / 注入 steering 消息 / 若配置了外部 backend 则直接委派
└───────────────┼───────────────┘
│
_chat_impl(...) chat_mixin.py:1974
│
┌───────────────┼───────────────┐
│ ② 组装:多模态附件 / 模板 / 知识检索(RAG)拼进 prompt;user 消息先入历史
└───────────────┼───────────────┘
│
┌────────┴────────┐ ← 这里岔成两条路(见第 5 节)
│ │
_using_custom_llm 路径 openai_client 路径
(LLM 实例 .get_response) _chat_completion(...) chat_mixin.py:909
│ │
│ ┌────────┴────────┐
│ │ ③ 算上下文预算 → 需要就压缩 _compute_context_budget_and_route:677
│ │ ④ BEFORE_LLM 钩子 / 预算硬闸
│ │ ⑤ 派发:_chat_completion_with_retry → dispatcher
│ └────────┬────────┘
│ │
└────────┬────────┘
│ ⑥ dispatcher 内部完成 "调工具↔再问" 循环(下沉,见第 5.3 节)
│
┌───────────────┼───────────────┐
│ ⑦ 收尾:自反思(可选)→ 护栏校验 → 回调/展示 → AFTER_AGENT 钩子 → 落历史
└───────────────┼───────────────┘
│
返回 最终字符串
一句话概括:chat 负责"接住这一句"、_chat_impl 负责"把这一轮编织出来"、_chat_completion 负责
"把一次 LLM 调用发好并处理容错",而"调工具-回填-再问"的循环本身被交给了 LLM 层的 dispatcher。
2.2 部件与职责
| 部件(方法/属性) | 干什么 | 位置 |
|---|---|---|
Agent 类 | 由一堆 mixin 拼成的单体;chat/achat 是对外主入口 | agent/agent.py:219 |
__init__ | 把几十个配置参数归一化成运行期状态(模型、工具、记忆、执行策略…) | agent/agent.py:533 |
_build_system_prompt | 把角色/目标/记忆/技能/工具清单/安全提示拼成系统提示 | agent/chat_mixin.py:32 |
_build_messages | 把 system + 历史 + 本轮 user 拼成 messages 列表 | agent/chat_mixin.py:315 |
_format_tools_for_completion | 把各种形态的工具统一成 OpenAI function схема | agent/chat_mixin.py:391 |
chat / _chat_impl | 同步主入口 / 主循环实现 | agent/chat_mixin.py:1910 / :1974 |
_chat_completion | 发起一次 LLM 调用,含预算/钩子/成本/容错 | agent/chat_mixin.py:909 |
_execute_unified_chat_completion | 把调用交给统一 dispatcher(工具循环下沉处) | agent/chat_mixin.py:1444 |
_compute_context_budget_and_route | 调用前算 token 预算,决定是否压缩 | agent/chat_mixin.py:677 |
iter_stream / _start_stream | 面向应用的流式迭代器 | agent/chat_mixin.py:3323 / :3365 |
achat / _achat_impl | 上面这套的异步孪生 | agent/chat_mixin.py:2524 / :2563 |
3. Agent 是怎么"拼"出来的(mixin 组合 + 庞大 init)
3.1 一个类,十一个 mixin
Agent 本身几乎不直接写方法,而是把能力横切成十来个 mixin,再用多重继承拼起来。真源码就一行:
class Agent(SteeringMixin, SandboxMixin, SkillReviewMixin, UnifiedExecutionMixin,
ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin,
ExecutionMixin, MemoryMixin, AsyncMemoryMixin):
依据:agent/agent.py:219。
为什么这么拆? 单个 agent.py 会膨胀到无法维护,于是把"聊天""工具执行""记忆""会话""沙箱"各自
拆进独立文件的 mixin,agent.py 顶部再逐个 from .chat_mixin import ChatMixin 引入
(agent/agent.py:15-27)。本章关心的一轮对话,主要住在 ChatMixin(agent/chat_mixin.py)里。
MRO(方法解析顺序)从左到右,越左越"外层"。和本章相关的几个:
| Mixin | 负责本章哪一块 | 文件 |
|---|---|---|
ChatMixin | chat/_chat_impl/_chat_completion/流式/预算 | agent/chat_mixin.py |
UnifiedExecutionMixin | 统一 dispatcher 的创建与调用胶水 | agent/unified_execution_mixin.py |
ToolExecutionMixin | execute_tool(本章的下界,详见第 02 章) | agent/tool_execution.py |
SteeringMixin | chat 开头的 steering 消息注入 | agent/message_steering.py |
3.2 __init__:把"配置"熨平成"状态"
Agent.__init__ 的签名极长(agent/agent.py:533 起),但它的职责单一:把用户五花八门的传参归一化。
它的设计约定是"每个特性参数三态":False=关、True=用默认、传 Config 对象=自定义。签名注释写得很清楚:
# Each follows: False=disabled, True=defaults, Config=custom
依据:agent/agent.py:557-559。像 memory、knowledge、context、execution 都遵循这个三态约定
(agent/agent.py:561-571)。本章只需记住:__init__ 跑完后,self.llm、self.tools、self.chat_history、
self.execution、self._memory_instance 等运行期字段就位,之后每次 chat 都读它们。
一个关键的性能取舍:重依赖是懒加载的。HookRunner、StreamEventEmitter、LLM 显示工具都做成属性/惰性
loader,不用就不导入——注释说这把静默模式的导入时间从 ~420ms 压到 ~20ms(agent/agent.py:32-36、
属性 _hook_runner 在 agent/agent.py:231)。
4. 一轮怎么"装":system prompt、messages、tools
在发起任何 LLM 调用前,Agent 要先把三样东西装好。这一节讲这三个组装器。
4.1 _build_system_prompt:把"我是谁"写清楚
系统提示不是一个静态字符串,而是按需层层追加拼出来的。核心骨架就三行:
system_prompt = f"""{self.backstory}\n
Your Role: {self.role}\n
Your Goal: {self.goal}"""
依据:agent/chat_mixin.py:55-57。
之后按开关依次往后追加(每一块都是"有才加"):
| 追加块 | 触发条件 | 依据 |
|---|---|---|
| Rules 规则 | 启用了 rules manager | chat_mixin.py:60-63 |
| Memory 记忆 | self._memory_instance 存在 | chat_mixin.py:66-101 |
| Learned Context | memory="learn" | chat_mixin.py:104-106 |
| Available Skills | 配了 skills | chat_mixin.py:109-113 |
| Session Context(平台感知) | 会话上下文里有 origin/targets | chat_mixin.py:121-159 |
| 工具清单 + "先解释再动手" | 有工具 | chat_mixin.py:172-197 |
| 安全:防提示注入 | trust 模块可用 | chat_mixin.py:200-204 |
一个容易踩的坑,代码专门处理了:缓存必须在注入会话上下文之前做。 因为角色/目标/工具是稳定的、可缓存,
而 session context 是每轮/每用户不同的,若把它一起缓存会导致跨用户串味。所以缓存动作被刻意放在
会话上下文注入之前(chat_mixin.py:115-118,注释:"Cache ... BEFORE adding session context";
以及末尾 chat_mixin.py:208 的再次说明)。启用 memory 时干脆整个不缓存(chat_mixin.py:52-53)。
4.2 _build_messages:拼出对话数组
messages 的组装有两条路:有 _openai_client 时委托它的 build_messages;否则手工拼
(chat_mixin.py:343-388)。手工路径的顺序很直白:
[system_prompt] ← 若 use_system_prompt
+ [持久化会话历史] ← 若开了 history 且有 session store
+ [内存里的 chat_history] ← 当前这轮之前的对话
+ [本轮 user 消息 / 多模态列表]
+ (可选) 尾部追加 "请按此 JSON schema 回复" ← 非原生结构化输出时
依据:agent/chat_mixin.py:344-387。
结构化输出这里有个分叉:若模型原生支持 response_format(_supports_native_structured_output,
chat_mixin.py:288),就不往文本里塞 schema,而是走原生字段;否则退化成"把 schema 当文本指令塞进最后一条
user 消息"(chat_mixin.py:376-387)。
4.3 _format_tools_for_completion:把杂七杂八的工具统一
工具在 PraisonAI 里可以是普通函数、字符串名、预格式化的 OpenAI dict、或带 to_openai_tool() 的 MCP 对象。
这个方法把它们统一成 OpenAI function schema 列表。
它有一个安全边界必须记住:None 与 [] 含义不同。
| 传入 | 含义 |
|---|---|
tools=None | 继承 agent 自己配置的 self.tools |
tools=[] | 显式拒绝所有工具(安全边界),立即返回空 |
依据:agent/chat_mixin.py:410-415,注释:"None (inherit) vs [] (explicit deny)"。这个区分在 _chat_impl
的自定义 LLM 路径里也再次出现(chat_mixin.py:2096-2103),是贯穿全流程的一条纪律。
此外它还做了三件收尾:JSON 可序列化校验(:472-477)、按函数名确定性排序以稳定缓存(:513-521)、
以及可选的 Tool Search 渐进式披露装配(:480-499)。工具内部怎么定义与执行属第 02 章。
5. 核心同步循环:chat → _chat_impl → _chat_completion → dispatcher
这是本章的心脏。先给一句最重要的结论,再拆:
在当前架构里,"解析 tool_calls → 执行 → 回填 → 再问"的多轮循环,并不写在
_chat_impl里, 而是被下沉进 LLM dispatcher。Agent这一层做的是:把execute_tool函数和max_iterations=10一起交给 dispatcher,由 dispatcher 在内部转那个循环。Agent只到"把execute_tool交出去、把 LLM 调好" 这个接缝为止。
5.1 chat:守门与委派
chat(chat_mixin.py:1910)本身很薄,只做入口守卫:
- slash 技能展开:
/skill-name args会被_resolve_skill_invocation先渲染成真正的 prompt(:1925)。 - steering 注入: 若有排队的引导消息,拼到 prompt 后面(
:1928-1935)。 - 外部 backend 委派: 若配了托管 backend,直接
_delegate_to_backend走人(:1938-1956)。 - 协作式取消: 调用前若 cancel token 已置位,抛
InterruptedError(:1964-1968)。 - 最后把活交给
_chat_impl,并用 trace emitter 包住首尾(:1970-1972)。
5.2 _chat_impl:把这一轮编织出来
_chat_impl(chat_mixin.py:1974)是真正干活的地方。前半段是组装(第 4 节已讲的三件事,加上多模态附件
:1982、响应模板 :1986-1996、知识检索 RAG 把 context 拼进 prompt :2058-2090)。然后岔成两条路:
_chat_impl
│
┌──────────┴──────────┐
self._using_custom_llm ? else(OpenAI 兼容路径)
│(True) │(False)
走 LLM 实例: 走 _chat_completion:
llm_instance.get_response( _build_messages → 进入 while 循环
execute_tool_fn=self.execute_tool, 每轮: _chat_completion(messages,...)
max_tool_calls_per_turn=10, → 拿到 response.choices[0].message
...) → 若非自反思: 落历史并返回
── 工具循环在 LLM 实例内部 → 若自反思: 追加反思 prompt 再转
依据:分叉在 agent/chat_mixin.py:2092(if self._using_custom_llm:)与 :2258(else:)。
注意 _chat_impl 里那个 while True(:2314)是"自反思(self-reflect)重生成"循环,不是工具循环。
它的作用是:模型答完后,让模型自评"满不满意",不满意就带着反思重新生成,直到满意或达到 max_reflect
(:2447 判满意、:2467 判上限)。默认 self_reflect=False 时,答一次就落历史返回(:2363-2388)。
无论哪条路,两个共同纪律值得记:
- user 消息在 LLM 调用之前就入历史(
:2151/:2289-2295),这样 handoff 能读到它。 - 失败要回滚历史:先存
chat_history_length(:2141/:2280),任何异常都_truncate_chat_history还原 (:2248/:2337/:2509),避免留下半截脏历史。
5.3 _chat_completion:一次调用的"全套武装"
OpenAI 路径每问一次模型,都走 _chat_completion(chat_mixin.py:909)。它在一次调用外面包了一整圈护甲,
顺序是:
_chat_completion(messages, tools, ...)
│
├─ 主动上下文预算 → 需要就压缩 _compute_context_budget_and_route:677
│ messages[:] = compacted (就地改,调用方能看到)
├─ 旧版 context 压缩(opt-in) _apply_context_compaction:3731
├─ BEFORE_LLM 钩子(可改写 messages) :934-949
├─ 预算硬闸:估算本次最小花费,超顶直接拒发 :956-972 (BudgetExceededError)
├─ 格式化工具 _format_tools_for_completion:391
├─ 智能流式回退:先试 stream=True,不支持就退非流式 :990-1024
├─ 真正派发 _chat_completion_with_retry:3896
│ → _execute_unified_chat_completion:1444
│ → dispatcher.chat_completion(execute_tool_fn=..., max_iterations=10) ← 工具循环在这
├─ 抽 token 用量 → 算成本 → 线程安全累加 :1056-1098 (_cost_lock)
├─ 预算超限:stop / warn / callback 三态 :1083-1098
└─ AFTER_LLM 钩子 :1101-1113
工具循环在哪? 就在 _execute_unified_chat_completion 把参数交给 dispatcher 那一刻。看这一段真源码:
final_response = self._unified_dispatcher.chat_completion(
messages=messages,
tools=tools,
execute_tool_fn=getattr(self, 'execute_tool', None), # ← 把"怎么执行工具"交出去
max_iterations=10, # ← 循环上限交出去
...
)
依据:agent/chat_mixin.py:1527-1561(流式首试版在 :1488-1508,同样传 execute_tool_fn 与 max_iterations=10)。
dispatcher 拿到这两样后,在它自己内部完成"模型要求调工具 → 用 execute_tool_fn 执行 → 把结果作为 tool
消息回填 → 再问模型 → 直到没有 tool_calls 或到 max_iterations"。这个循环的实现属第 03 章。
这就是本章的下界: Agent 只负责把 execute_tool(第 02 章)和一次次 LLM 调用(第 03 章)接到一起,
循环本身它不亲自转。
5.4 错误分类 → 三种自愈(压缩/换模型/重试)
_chat_completion 的 except 块不是简单地抛错,而是先分类再自愈(chat_mixin.py:1119-1264)。它调
classify_llm_error 得到一个带"恢复建议"的分类对象,然后据此选路:
| 分类信号 | 动作 | 依据 |
|---|---|---|
should_compress_context | 紧急截断到模型上限 70%,递归重试(深度上限 2) | chat_mixin.py:1166-1186 |
should_fallback_model | 换 fallback 链下一个模型,清 dispatcher 缓存后递归 | chat_mixin.py:1193-1218 |
is_retryable + 有退避 | time.sleep(backoff) 后递归(深度上限 2) | chat_mixin.py:1223-1232 |
| 都不行 | 包装成 LLMError 抛出,先触发 on_error 钩子 | chat_mixin.py:1245-1264 |
分类器本身(classify_llm_error)属第 03 章;本章只需知道 Agent 在调用点接住异常并驱动这三种自愈。
换模型时的取值靠 _get_next_fallback_model(chat_mixin.py:894)——它就是从 self.fallback_models 里按索引取下一个。
6. 上下文预算与压缩:别把窗口撑爆
LLM 有上下文窗口上限,历史一长就会溢出。PraisonAI 的策略是默认开、调用前主动算,而不是等报错再补救。
6.1 主动预算:调用前就决定怎么办
_compute_context_budget_and_route(chat_mixin.py:677)在每次 LLM 调用之前跑一遍:按策略估算当前
token 占用,产出一个"路由"决定,并就地返回(可能压缩过的)messages。四种路由:
| 路由(CompactionRoute) | 含义 | 处理 |
|---|---|---|
FITS | 装得下 | 原样返回 |
COMPACT_NEEDED | 需压缩 | _apply_compaction(:729) |
TRUNCATE_TOOLS | 砍工具输出 | _apply_tool_truncation(:771) |
COMPACT_THEN_TRUNCATE | 先压缩,不够再砍工具 | 先压后判,:719-725 |
依据:agent/chat_mixin.py:697-727。策略从哪来?_get_compaction_policy(:617)读 execution.context_compaction:
False=关、True=默认策略、传对象=自定义。
6.2 两种"瘦身"手法
手法一,压缩历史(_apply_compaction,:729): 把 policy 的策略名映射到具体 compactor 策略
(truncate / summarise / drop_oldest_tools / sliding_window,:736-743),压缩前后各触发一次
BEFORE_COMPACTION/AFTER_COMPACTION 钩子。
手法二,只砍工具输出(_apply_tool_truncation,:771): 更外科手术——只对 role=="tool" 且内容超 1000 字符
的消息动刀,保留头 300 尾 200,中间换成 ...[truncated N chars]...(:782-787)。这样保住对话结构,只压最占地方的
工具大输出。
6.3 还有一层"硬上限保险"
即便压过,_apply_context_management(chat_mixin.py:1713)还会再兜一次底:若 token 仍超模型上限的 95%,
就紧急截断到 80%(:1806-1812)。它整块包在 try/except 里,注释明说"上下文管理绝不能打断聊天流"
(:1820-1823),失败就原样放行。这三层(主动预算 → 旧版压缩 → 硬上限保险)是层层加固的关系。
7. 流式:iter_stream / _start_stream
前面讲的是"一次拿到整段字符串"。若要边生成边显示,走流式。
iter_stream(chat_mixin.py:3323)是面向应用的迭代器:强制 stream=True、默认不往终端打印,逐块 yield
文本,用完自动存会话(:3356-3363)。真正干活的是 _start_stream(:3365),它也按 _using_custom_llm 分两条路。
OpenAI 流式路径里能直接看到一次工具处理(:3548-3663):边收 chunk 边累加文本与 tool_calls_data,
流结束后若攒到了 tool_calls,就把 assistant 消息(带 tool_calls)入历史,再逐个 execute_tool 执行、把
tool 结果入历史(:3621-3663)。
但要诚实指出一个边界:这条流式路径执行完工具后并不会自动"再问一轮模型"——它把工具结果落进历史就结束了
(:3664 之后直接收尾)。也就是说,iter_stream 是单趟流式,不做非流式 chat 那种由 dispatcher 驱动的
多轮工具编织。要多轮工具循环,用非流式的 chat。(inferred:基于 _start_stream 无回环到 LLM 调用的读码判断。)
出错时它有优雅降级:流式失败就回退到普通 self.chat(prompt),再把结果切成词块模拟流式吐出(:3669-3695)。
8. 重试:_chat_completion_with_retry
第 5.4 节的自愈是"分类后按类型自愈";这一层是通用的抖动指数退避重试,专治瞬时故障(限流、网络、服务抖动)。
_chat_completion_with_retry(chat_mixin.py:3896)包在 _execute_unified_chat_completion 外面:没配
_retry_config 就直接透传(:3903-3907);配了就进 for attempt in range(max_attempts) 循环。它的克制之处:
- 只重试可重试的
LLMError:非LLMError或is_retryable=False立即抛(:3925-3927)。 - 退避量由
jittered_backoff按 attempt 算(:3934-3939),每次重试前触发ON_RETRY钩子(:3942-3955)。 - 中断优先:退避睡眠期间若检测到中断,直接抛
RuntimeError终止重试,不再无谓等待(:3960-3967)。
所以 Agent 一次 LLM 调用其实有两层容错叠加:外层 这个通用退避重试,内层 _chat_completion 里的分类自愈
(压缩/换模型/递归)。
9. 异步孪生:achat / _achat_impl
上面整套都有一份异步镜像,行为对齐但用 async 原语。入口 achat(chat_mixin.py:2524)与 chat 几乎逐行对应:
同样先 _resolve_skill_invocation、注入 steering、包 trace,然后 await self._achat_impl(...)(:2551-2559)。
_achat_impl 在 :2563。
配套的异步版本还有:_compute_context_budget_and_route_async(:803)、_apply_compaction_async(:848)、
_apply_context_compaction_async(:3816)、_achat_completion_with_retry(:3972)、错误处理
_handle_async_llm_error(:1266)。异步派发同样走 _execute_unified_achat_completion(:1568),照样把
execute_tool_fn 与 max_iterations 交给 dispatcher。一个差别:异步派发默认 stream=True(:1573),因为异步适配器
普遍支持流式,不需要同步那种"先试流式再回退"的智能回退。
10. 巧妙之处(可借鉴)
-
Nonevs[]的工具语义分离。 用两个"空值"