标签驱动的 agent 循环(核心引擎)
这章讲什么: DeepTutor 所有能力(chat / solve / research / mastery…)共用的那条「传送带」。理解它,就理解了 DeepTutor 的 agent-native 是怎么落地的。
1. 它要解决的小问题
LLM 一次回复是一团文本。但一个 agent 回合里,模型可能想做几件性质完全不同的事:
- 「我先想想」——这是推理,该进思考侧栏,不该进答案。
- 「我要查知识库」——这是调工具,该去真执行,把结果喂回去。
- 「我说完了,这是答案」——这是收尾,该作为正文流给用户。
问题:怎么在流式输出里,实时分清模型这一段到底想干哪件事,并据此路由? 而且最好让所有能力共用一套机制,不要每个能力各写一遍。
2. 思路 / 直觉:让模型自己报标签
DeepTutor 的答案是一个协议:要求模型每次回复的第一行报一个双反引号包裹的标签,比如 THINK 、 TOOL 、 FINISH ,后面才是内容(见 deeptutor/core/agentic/labels.py 顶部 docstring)。
标签词表是调用方提供的:聊天用 (FINISH, TOOL, THINK),一个 solve 步用 (THINK, TOOL, FINISH, REPLAN),plan 用 (PLAN,)……(labels.py:9-10)。
循环只认标签,把它分成四类(deeptutor/core/agentic/loop.py:38,LabelProtocol):
| 标签类别 | 含义 | 循环的动作 |
|---|---|---|
terminal | 终结标签(如 FINISH) | 退出循环,这就是结果 |
intermediate | 中间标签(如 THINK) | 把这段当上下文留住,继续下一轮 |
final | 文字该作为「正文」发给用户的标签 | 通过 host.emit_final 发出去 |
tool_label | 「这一轮要调工具」的那个标签(如 TOOL) | 分发工具调用 |
注意 final 和 terminal/intermediate 是正交的:一个终结标签可以不发正文(如 REPLAN 只是把文字往上冒);一个中间标签也可以发正文(聊天的 PAUSE——边推理边对用户说话,但不结束回合)(loop.py:44-54)。
3. 图示:循环的一轮怎么走
怎么读这张图: 从上到下是一轮迭代;菱形是「看标签属于哪类」的分叉;命中 terminal 即停,否则回到顶部进下一轮。
┌──────────────────────────────────────────┐
│ 一轮迭代开始 │
│ ① guard_context_window(裁剪上下文窗口) │
│ ② before_iteration(可选:注入「第N/M步」) │
└───────────────────┬──────────────────────┘
▼
run_labeled_step:一次流式 LLM 调用
(解析首行标签,推理流→思考侧栏,正文→缓冲)
│
▼
┌───────────────────────┐
┌───────┤ 这个标签属于哪一类? ├───────┐
│ └───────────┬───────────┘ │
协议违规│ tool │ intermediate │ terminal
▼ ▼ ▼
发重试通知,把 追加 assistant (可选)校验
草稿留作上下文, 上下文,跑可选 ↓ 通过
注入修复提示 → on_intermediate 若属 final → emit_final
下一轮 钩子 → 下一轮 标记 completed,退出
│
(tool 分支)──► dispatch_tools 并行执行
→ 把 role=tool 结果塞回对话
→ pause? terminate? 否则继续下一轮
源码主体就是 loop.py:226 起的那个 for iteration in range(max_iter) 循环。
4. 核心机制逐个看
4.1 单步调用:run_labeled_step 怎么把推理和正文分流
这是整条循环里工程含量最高的一支(deeptutor/core/agentic/labeled_step.py:197,run_labeled_step)。它驱动一次流式 LLM 调用,边收 chunk 边做四件事:
- 探标签:前几个 chunk 攒进
label_buf,交给classify_label试着认出首行的LABEL(labeled_step.py:449)。 - 认出后分流:标签若属于
final_labels,后续文字缓冲(等协议校验通过再发,避免FINISH+TOOL混合回复把半截 prose 泄进答案区);否则实时stream.thinking进推理侧栏(labeled_step.py:288-316,_emit_text)。 - 攒工具调用 delta:把流式的
tool_calls分片拼起来(labeled_step.py:587-600)。 - 处理 reasoning 模型的两种「思考」:有的模型(DeepSeek-R1 等)用独立的
reasoning_content字段吐思维链(labeled_step.py:566-578);有的把<think>...</think>内联在正文前——专 门有个前置状态机_ingest_pre_label识别并把这段 think 也分流进推理侧栏(labeled_step.py:400)。
精妙处: 标签的存在与否不靠「有没有 tool_calls」来推断——哪怕模型吐了 tool_calls,正文流也必须以工具标签开头,否则走协议修复(labeled_step.py:14-17 docstring)。这把「模型乱来」和「正常调工具」干净分开。
下面这段教学示意,演示分流的核心思想(不是源码):
# 示意,非源码:边收流边把首行标签认出来,然后按标签路由后续文字
label = None
buf = ""
async for chunk in llm_stream: # 流式收每个增量
text = chunk.delta.content or ""
if label is None:
buf += text
parsed = classify_label(buf, allowed=("THINK", "TOOL", "FINISH"))
if parsed: # 认出了首行标签
label, after = parsed
route(label, after) # 把标签后的残余文字先路由一次
else:
route(label, text) # 标签已知:后续 chunk 直接按它走
def route(label, text):
if label == "FINISH":
buffer_as_answer(text) # 正文:缓冲,等校验通过再发
else:
emit_to_thinking_sidebar(text) # THINK/TOOL:实时进思考侧栏
重点看: 标签一旦认出就锁定,后续 chunk 不再重复探测——这让流式路由是 O(1) 的。
4.2 标签解析的容错:classify_label
模型经常把协议写歪。解析器(deeptutor/core/agentic/labels.py:34,classify_label)容忍一堆变体:
- 反引号数量不对(1 个 / 3 个都试)、标签前有零宽字符或空白(
strip_label_probe_prefix,labels.py:24)。 - 裸标签兜底:模型忘了反引号时,只要
FINISH后面跟一个明确分隔符(换行/空格/冒号),也认——但要防止把正文里的FINISHED误判(labels.py:89-92)。 - 流到一半还没认出来就继续攒,超过
LABEL_PROBE_MAX_CHARS(64)还没匹配,就判LABEL_UNKNOWN交给修复路径(labeled_step.py:456-464)。
4.3 协议违规的自我修复
循环每轮先做协议体检(loop.py:387,_protocol_violation):没标签(missing_label)、正文里又冒出第二个标签(multiple_labels)、报了工具标签却没 tool_calls(tool_without_calls)、报了非工具标签却带了 tool_calls({label}_with_tools)……
违规时不报错中断,而是:发一条重试通知 → 把模型的无标签草稿截断(最多 500 字)留作 assistant 上下文 → 注入一条「你该怎么改」的修复 user 消息 → 下一轮重试(loop.py:431,_append_repair_messages)。修复文案由 host 提供(host.protocol_repair_message)。
4.4 工具分发:并行 + 去重 + pause/terminate
报了工具标签,循环把工具调用交给 dispatch_tool_calls(deeptutor/core/agentic/tool_dispatch.py:72)。要点:
- 并行执行,但上限
MAX_PARALLEL_TOOL_CALLS = 8(tool_dispatch.py:41),超了截断并提示。 - 批内去重:模型偶尔在一 条 assistant 消息里发重复 tool_calls。同名 + 同参数的算重复;
ask_user更严——同批里第二个ask_user无论参数都算重复(因为运行时只能为一个 pending 问题暂停)(tool_dispatch.py:191,_detect_duplicate_calls)。重复的不真跑,而是返回一条给模型看的提示,告诉它别再发相同并行调用(tool_dispatch.py:226,_duplicate_stub_result)。 - 每个工具调用都开一张独立的 trace 子卡(
_build_per_tool_trace_meta),前端能看到一条条工具行。 - 结果汇成
DispatchOutcome,携带role=tool消息、累计的 sources、以及pause/terminate信号。pause(如ask_user)让回合挂起等用户回复;terminate让某个工具的内容成为终结产物(tool_dispatch.py:49-69)。
5. 巧妙之处(可借鉴)
- 能力无关的循环 + host 回调缝合点。 循环核心一行不改,新能力只要声明一套
LabelProtocol+ 实现一组LoopHost回调(裁窗、trace 元数据、工具分发、修复文案、强制收尾…)(loop.py:78,LoopHost)。连可选钩子都用getattr探测——老 host 不实现before_iteration/on_intermediate/validate_terminal也照常工作(loop.py:228、loop.py:345)。 - 正文缓冲 vs 实时流的开关。
stream_body_live/final_meta决定 final-label 的文字是「一次性发」还是「chunk-by-chunk 实时流进答案气泡」,默认缓冲以兼容聊天既有行为(loop.py:206-211)。 - 网关挂连接的兜底。 有些 OpenAI 兼容网关在
finish_reason后还把 SSE 连接吊着等 usage 帧——它用 1 秒宽限超时拿 usage,拿不到就本地关流,UI 不卡(labeled_step.py:80,_USAGE_TRAILER_GRACE_TIMEOUT_S);还有 8 秒的 final-label 空闲兜底(_FINAL_LABEL_IDLE_TIMEOUT_S)。 - provider 能力探测式降级。 调用失败时按错误文本判断是不是「不支持 stream_options / 不支持原生工具 schema / 不支持图片输入」,分别去掉对应参数重试,而不是硬失败(
labeled_step.py:474,_create_response_stream;判定见_is_tool_schema_unsupported等)。
6. 边界与局限
- 标签协议依赖模型听话地把标签放第一行。差模型会频繁触发修复重试,吃迭代预算(
max_iterations);预算耗尽走host.force_finalize强制收尾(loop.py:368-375)。 - 标签是英文大写 token,裸标签兜底依赖「全大写 + 分隔符」的无歧义性;若某语言正文恰好以这种 token 开头,理论上可能误判(已用「后跟分隔符」缓解)。
- 协议本身是 prompt 约定,不是 API 强约束;
run_labeled_step里implicit_think_label参数为兼容旧调用而保留,但被有意忽略——推理痕迹只是 trace 数据,不是循环动作,正式标签必须来自正文流(labeled_step.py:235-239)。
7. 代码地图
| 主题 | 文件 | 符号 |
|---|---|---|
| 循环调度核心 | deeptutor/core/agentic/loop.py | run_agentic_loop、LabelProtocol、LoopHost、_protocol_violation、_append_repair_messages |
| 单步 LLM 调用 + 分流 | deeptutor/core/agentic/labeled_step.py | run_labeled_step、_ingest_pre_label、_emit_text、_create_response_stream |
| 标签解析 | deeptutor/core/agentic/labels.py | classify_label、strip_label_probe_prefix、find_inline_labels |
| 并行工具分发 | deeptutor/core/agentic/tool_dispatch.py | dispatch_tool_calls、_detect_duplicate_calls、execute_tool_call、DispatchOutcome |