跳到主要内容

标签驱动的 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)分发工具调用

注意 finalterminal/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 边做四件事:

  1. 探标签:前几个 chunk 攒进 label_buf,交给 classify_label 试着认出首行的 LABEL (labeled_step.py:449)。
  2. 认出后分流:标签若属于 final_labels,后续文字缓冲(等协议校验通过再发,避免 FINISH+TOOL 混合回复把半截 prose 泄进答案区);否则实时 stream.thinking 进推理侧栏(labeled_step.py:288-316,_emit_text)。
  3. 攒工具调用 delta:把流式的 tool_calls 分片拼起来(labeled_step.py:587-600)。
  4. 处理 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:228loop.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_stepimplicit_think_label 参数为兼容旧调用而保留,但被有意忽略——推理痕迹只是 trace 数据,不是循环动作,正式标签必须来自正文流(labeled_step.py:235-239)。

7. 代码地图

主题文件符号
循环调度核心deeptutor/core/agentic/loop.pyrun_agentic_loopLabelProtocolLoopHost_protocol_violation_append_repair_messages
单步 LLM 调用 + 分流deeptutor/core/agentic/labeled_step.pyrun_labeled_step_ingest_pre_label_emit_text_create_response_stream
标签解析deeptutor/core/agentic/labels.pyclassify_labelstrip_label_probe_prefixfind_inline_labels
并行工具分发deeptutor/core/agentic/tool_dispatch.pydispatch_tool_calls_detect_duplicate_callsexecute_tool_callDispatchOutcome