Hermes Agent Token 机制深度解析
从一个简单的问题出发,逐层深入 Hermes Agent 的源码,完整拆解 LLM Agent 框架如何管理 token 预算、上下文窗口、自动压缩、图片处理与 API 请求构建的全流程。
源码版本:2026 年 8 月,基于 Hermes Agent 本地仓库。
一、起点:Agent 的输出有 token 限制吗?
第一个问题是:Hermes Agent 有没有对 agent 输出内容做 max token 设置?
答案是有,而且是四层防线协作。
第一层:请求时设置 max_tokens 上限
每次调 API 时,通过 _max_tokens_param() 把上限传给模型。参数名因 provider 不同:
python
# run_agent.py:971-982 --- _max_tokens_param() 定义
def _max_tokens_param(self, value: int) -> dict:
"""Return the correct max tokens kwarg for the current provider.
OpenAI's newer models (gpt-4o, o-series, gpt-5+) require
'max_completion_tokens'. Azure OpenAI also requires
'max_completion_tokens' for gpt-5.x models. OpenRouter,
local models, and older OpenAI models use 'max_tokens'.
"""
if self._is_direct_openai_url() or self._is_azure_openai_url() or self._is_github_copilot_url():
return {"max_completion_tokens": value}
return {"max_tokens": value}
来源是 agent.max_tokens,默认从 models.dev 自动获取模型上限。可在 config.yaml 里覆盖:
python
# agent_init.py:1228-1254 --- config.yaml 中的 max_tokens 覆盖
# Read explicit model output-token override from config when the
# caller did not pass one directly.
_model_cfg = _agent_cfg.get("model", {})
if agent.max_tokens is None and isinstance(_model_cfg, dict):
_config_max_tokens = _model_cfg.get("max_tokens")
if _config_max_tokens is not None:
try:
if isinstance(_config_max_tokens, bool):
raise ValueError
_parsed_max_tokens = int(_config_max_tokens)
if _parsed_max_tokens <= 0:
raise ValueError
agent.max_tokens = _parsed_max_tokens
except (TypeError, ValueError):
_ra().logger.warning(
"Invalid model.max_tokens in config.yaml: %r --- "
"must be a positive integer (e.g. 4096). "
"Falling back to provider default.",
_config_max_tokens,
)
agent._session_init_model_config["max_tokens"] = agent.max_tokens
第二层:检测截断(finish_reason = "length")
API 返回后,检查 finish_reason:
python
# conversation_loop.py:1388-1416 --- 截断检测
if status == "incomplete" and incomplete_reason in {"max_output_tokens", "length"}:
finish_reason = "length"
if finish_reason == "length":
# ⚠️ 输出被截断了
还额外检测 Ollama/GLM 的"假完成"------有些模型明明被截断了却返回 "stop",Hermes 用 _should_treat_stop_as_truncated() 识别这种情况。
第三层:自动续写(最多 2 轮)
检测到截断后,自动发一条续写指令让模型从断点继续。详见第二章。
第四层:输入溢出时自动缩减
如果 input + max_tokens > context_window 导致 API 报错,自动缩减输出上限。详见第三章。
二、续写机制:如何保证连贯性?
完整续写流程(以第 1 次截断为例)
Step 1 :模型输出被截断,finish_reason = "length"。
Step 2:把截断的半截内容存进 messages 数组:
python
# conversation_loop.py:1501-1507 --- 截断内容入历史
assistant_message = _trunc_msg # API返回的半截内容
interim_msg = agent._build_assistant_message(assistant_message, finish_reason)
messages.append(interim_msg) # ← 半截 assistant 消息进入对话历史
if assistant_message.content:
truncated_response_parts.append(assistant_message.content) # ← 同时存一份碎片
_build_assistant_message(定义于 run_agent.py:3708,实现于 chat_completion_helpers.py:493)做了清洗------剥离 <think> 标签、清理 surrogate 字符,但不修改正文内容------半截文字原样保留。
Step 3:插入续写指令:
python
# conversation_loop.py:1514-1522 --- 续写指令
continue_msg = {
"role": "user",
"content": (
"[System: Your previous response was truncated by the output "
"length limit. Continue exactly where you left off. Do not "
"restart or repeat prior text. Finish the answer directly.]"
),
}
messages.append(continue_msg)
这是连贯性的关键。模型在下一次 API 调用时看到的是:
markdown
[assistant]: "第一段内容...这是半截的句" ← 被截断的半截
[user]: "[System: ...Continue exactly where you left off...]"
模型从自己的半截输出自然续写。连贯性由模型自身保证,不是 Hermes 做的。
Step 4:下一轮 API 调用前提高 max_tokens:
python
# conversation_loop.py:3000-3006 --- 递增输出预算
if restart_with_length_continuation:
# Progressively boost the output token budget on each retry.
# Retry 1 → 2× base, retry 2 → 3× base, capped at 32 768.
# Applies to all providers via _ephemeral_max_output_tokens.
_boost_base = agent.max_tokens if agent.max_tokens else 4096
_boost = _boost_base * (length_continue_retries + 1)
agent._ephemeral_max_output_tokens = min(_boost, 32768)
continue
| 轮次 | 实际预算 | 累计上限 |
|---|---|---|
| 原始调用 | max_tokens(如 8192) | 8192 |
| 续写第 1 轮 | ×2 = 16384 | 24576 |
| 续写第 2 轮 | ×3 = 24576(封顶 32768) | 49152 |
第 3 次截断后不再续写(length_continue_retries < 3 条件不满足),返回已拼接的 partial response。
Step 5:续写成功 → 拼接碎片:
python
# conversation_loop.py:3807-3812 --- 最终拼接
if truncated_response_parts:
final_response = "".join(truncated_response_parts) + final_response
truncated_response_parts = []
final_response = agent._strip_think_blocks(final_response).strip()
注意:纯字符串拼接,没有任何智能合并 。"".join(parts) + final 就是直接首尾相连。
连贯性到底靠什么保证?
| 机制 | 谁负责 | 具体做法 |
|---|---|---|
| 从正确位置续写 | 模型自身 | 模型看到自己半截的 assistant 消息,自然从断点继续 |
| 不重复已说内容 | System 指令 | "Do not restart or repeat prior text" |
| 续写空间够用 | Hermes | 每轮递增 max_tokens(2x→3x) |
| 最多 2 轮续写兜底 | Hermes | 超过 2 次续写返回 partial response |
| 最终拼接 | Hermes | "".join(parts) + final,直接首尾相连 |
特殊情况:思维链耗尽
如果模型把所有 token 花在 reasoning 上(<think> 标签里),没有留给正文,续写没意义:
python
# conversation_loop.py:1458-1465 --- 思维链耗尽检测
_thinking_exhausted = (
not _trunc_has_tool_calls
and _has_think_tags
and not _has_content_after_think_block(_trunc_content)
)
# → 返回 "Thinking Budget Exhausted" 提示,让用户降低 reasoning effort
三、max_tokens 设置的意义是什么?
既然有续写兜底,max_tokens 的意义在哪里?
3.1 它是 API 的必需参数
每家 LLM API 都要求传 max_tokens。不传就用默认值(通常是 4096),你无法"不设"它。
3.2 控制单次调用的成本和延迟
max_tokens 越大,模型倾向输出越长、等待越久、费用越高。设 8192 意味着绝大多数回复一轮就完成。
3.3 保护 context window 不溢出
input_tokens + max_tokens ≤ context_window
如果 input 占了 120K,context window 是 128K,max_tokens 就必须 ≤ 8K。Hermes 那套"自动缩减"逻辑就是为了应对这个约束。
3.4 续写是兜底,不是常态
max_tokens 是每轮的预算,续写是超预算时的应急贷款。两者配合:用合理的默认预算控制成本,用续写兜底保证体验。
四、max_tokens 可以不设吗?context_window 怎么算?
4.1 不设 max_tokens = 用 API 默认值,不是模型最大值
初始化时默认就是 None:
python
# agent_init.py:179, 458 --- max_tokens 默认 None
max_tokens: int = None,
...
agent.max_tokens = max_tokens # None = use model default
当 None 时,不向 API 传这个参数,用各家的默认值:
| 提供商 | 不设 max_tokens 的后果 |
|---|---|
| OpenAI | API 默认 4096(不是模型最大值) |
| Anthropic | API 必须传,否则报错 |
| Bedrock | Hermes 兜底:max_tokens or 4096 |
4.2 context_window 的计算:10 级优先级链
核心函数是 get_model_context_length():
python
# model_metadata.py:1429-1460 --- context_window 解析优先级
def get_model_context_length(
model: str,
base_url: str = "",
api_key: str = "",
config_context_length: int | None = None,
provider: str = "",
custom_providers: list | None = None,
) -> int:
"""Get the context length for a model.
Resolution order:
0. Explicit config override (model.context_length or custom_providers per-model)
1. Persistent cache (previously discovered via probing)
1b. AWS Bedrock static table (must precede custom-endpoint probe)
2. Active endpoint metadata (/models for explicit custom endpoints)
3. Local server query (for local endpoints)
4. Anthropic /v1/models API
5. Provider-aware lookups (before generic OpenRouter cache):
a. Copilot live /models API
b. Nous: live probe first, then OR cache fallback
c. Codex OAuth /models probe
d. GMI /models endpoint
e. Ollama native /api/show probe (any base_url, provider-agnostic)
f. models.dev registry lookup
6. OpenRouter live API metadata
7. Hardcoded defaults (broad family patterns, longest-key-first)
8. Local server query (last resort)
9. Default fallback (256K)
"""
比如 GLM-5 走优先级 7 硬编码:
python
# model_metadata.py:205
"glm": 202752, # ≈198K
context_window 一旦确定,就被存入 context_compressor.context_length,后续所有压缩判断都基于这个值。
4.3 自动压缩的触发时机
压缩有三个完全不同的触发点。
触发点 A:预飞行压缩(对话开始前)
在进入主循环之前,估算整个 messages 数组 + 系统提示 + 工具 schema 的总 token,超过阈值就压缩:
python
# conversation_loop.py:467-533 --- 预飞行压缩
# ── Preflight context compression ──
# Before entering the main loop, check if the loaded conversation
# history already exceeds the model's context threshold. This handles
# cases where a user switches to a model with a smaller context window
# while having a large existing session --- compress proactively rather
# than waiting for an API error (which might be caught as a non-retryable
# 4xx and abort the request entirely).
if (
agent.compression_enabled
and len(messages) > agent.context_compressor.protect_first_n
+ agent.context_compressor.protect_last_n + 1
):
# Include tool schema tokens --- with many tools these can add
# 20-30K+ tokens that the old sys+msg estimate missed entirely.
_preflight_tokens = estimate_request_tokens_rough(
messages,
system_prompt=active_system_prompt or "",
tools=agent.tools or None,
)
if _preflight_tokens >= agent.context_compressor.threshold_tokens:
# May need multiple passes for very large sessions with small
# context windows (each pass summarises the middle N turns).
for _pass in range(3):
_orig_len = len(messages)
messages, active_system_prompt = agent._compress_context(
messages, system_message, approx_tokens=_preflight_tokens,
task_id=effective_task_id,
)
if len(messages) >= _orig_len:
break # Cannot compress further
# Re-estimate after compression
_preflight_tokens = estimate_request_tokens_rough(
messages,
system_prompt=active_system_prompt or "",
tools=agent.tools or None,
)
if _preflight_tokens < agent.context_compressor.threshold_tokens:
break # Under threshold
关键细节:不是和 context_window(100%)比,是和 50% 比。 剩下 50% 用来容纳模型回复 + 工具调用结果 + 新消息。
触发点 B:工具调用后压缩(对话过程中)
每轮工具调用完成后,用 API 返回的精确 prompt_tokens 判断是否需要压缩:
python
# conversation_loop.py:3459-3500 --- 工具调用后压缩
# Use real token counts from the API response to decide
# compression. prompt_tokens + completion_tokens is the
# actual context size the provider reported plus the
# assistant turn --- a tight lower bound for the next prompt.
#
# If last_prompt_tokens is 0 (stale after API disconnect
# or provider returned no usage data), fall back to rough
# estimate.
_compressor = agent.context_compressor
if _compressor.last_prompt_tokens > 0:
# Only use prompt_tokens --- completion/reasoning
# tokens don't consume context window space.
# Thinking models (GLM-5.1, QwQ, DeepSeek R1)
# inflate completion_tokens with reasoning,
# causing premature compression. (#12026)
_real_tokens = _compressor.last_prompt_tokens
else:
# Include tool schemas --- with 50+ tools enabled
# these add 20-30K tokens the messages-only
# estimate misses.
_real_tokens = estimate_request_tokens_rough(
messages, tools=agent.tools or None
)
if agent.compression_enabled and _compressor.should_compress(_real_tokens):
agent._safe_print(" ⟳ compacting context...")
messages, active_system_prompt = agent._compress_context(
messages, system_message,
approx_tokens=agent.context_compressor.last_prompt_tokens,
task_id=effective_task_id,
)
触发点 C:错误恢复压缩(API 报错后)
API 返回 context overflow 错误时,压缩后重试:
python
# conversation_loop.py:2589-2679 --- 错误恢复压缩
# Check for context-length errors BEFORE generic 4xx handler.
is_context_length_error = (
classified.reason == FailoverReason.context_overflow
)
if is_context_length_error:
compressor = agent.context_compressor
old_ctx = compressor.context_length
# ── Distinguish two very different errors ───────────
# 1. "Prompt too long": the INPUT exceeds the context window.
# Fix: reduce context_length + compress history.
# 2. "max_tokens too large": input is fine, but
# input_tokens + requested max_tokens > context_window.
# Fix: reduce max_tokens (the OUTPUT cap) for this call.
# Do NOT shrink context_length --- the window is unchanged.
available_out = parse_available_output_tokens_from_error(error_msg)
if available_out is not None:
# Error is purely about the output cap being too large.
# Cap output to the available space and retry without
# touching context_length or triggering compression.
safe_out = max(1, available_out - 64) # small safety margin
agent._ephemeral_max_output_tokens = safe_out
restart_with_compressed_messages = True
break
# Error is about the INPUT being too large --- reduce context_length.
parsed_limit = parse_context_limit_from_error(error_msg)
if parsed_limit and parsed_limit < old_ctx:
new_ctx = parsed_limit
else:
# Step down to the next probe tier
new_ctx = get_next_probe_tier(old_ctx)
if new_ctx and new_ctx < old_ctx:
compressor.update_model(
model=agent.model,
context_length=new_ctx,
base_url=agent.base_url,
)
4.4 三道压缩防线的时间线
css
用户发消息
│
▼
┌──────────────────────────────────────────────┐
│ 防线 A:预飞行压缩(对话开始前) │
│ 估算 tokens ≥ context_window × 50%?→ 压缩 │
└──────────────────────────────────────────────┘
│ 通过(或压缩后通过)
▼
┌──────────────────────────────────────────────┐
│ 进入主循环:API 调用 → 工具执行 → API 调用... │
│ │
│ 防线 B:工具调用后压缩(每轮检查) │
│ API 报告 precise prompt_tokens ≥ 50%?→ 压缩 │
└──────────────────────────────────────────────┘
│
│ 如果防线 A 和 B 都错过了...
▼
┌──────────────────────────────────────────────┐
│ 防线 C:API 报错后压缩 │
│ context_overflow error → 缩减 context_window │
│ 或缩减 max_tokens → 压缩 → 重试 │
└──────────────────────────────────────────────┘
压缩阈值计算
python
# context_compressor.py:512-563 --- 压缩阈值初始化
def __init__(self, ..., threshold_percent: float = 0.50, ...):
self.threshold_percent = threshold_percent # 默认 50%
self.context_length = get_model_context_length(model, ...)
# Floor: never compress below MINIMUM_CONTEXT_LENGTH tokens
# even if the percentage would suggest a lower value.
self.threshold_tokens = max(
int(self.context_length * threshold_percent),
MINIMUM_CONTEXT_LENGTH, # = 64_000
)
所以 threshold_tokens = max(context_window × 50%, 64000)。比如 200K 窗口 → 100K 阈值。
防抖保护
python
# context_compressor.py:613-633 --- should_compress 与防抖
def should_compress(self, prompt_tokens: int = None) -> bool:
"""Check if context exceeds the compression threshold.
Includes anti-thrashing protection: if the last two compressions
each saved less than 10%, skip compression to avoid infinite loops.
"""
tokens = prompt_tokens if prompt_tokens is not None else self.last_prompt_tokens
if tokens < self.threshold_tokens:
return False
# Anti-thrashing: back off if recent compressions were ineffective
if self._ineffective_compression_count >= 2:
logger.warning(
"Compression skipped --- last %d compressions saved <10%% each.",
self._ineffective_compression_count,
)
return False
return True
4.5 max_tokens 自动扩展只在截断时触发吗?
是的。 但 _ephemeral_max_output_tokens 有两个方向:
| 方向 | 触发条件 | 代码位置 | 做什么 |
|---|---|---|---|
| 扩展 ↑ | finish_reason = "length"(输出被截断) |
:3000-3006 | 2x → 3x → 封顶 32768 |
| 缩减 ↓ | API 报 "input + max_tokens > window" | :2611-2617 | 缩减到 可用空间 - 64 |
缩减方向代码:
python
# conversation_loop.py:2611-2617 --- 缩减输出预算
available_out = parse_available_output_tokens_from_error(error_msg)
if available_out is not None:
safe_out = max(1, available_out - 64) # small safety margin
agent._ephemeral_max_output_tokens = safe_out
# Do NOT shrink context_length --- the window is unchanged.
这个变量是一次性的------用完即清:
python
# chat_completion_helpers.py:257-259/413-415/447-449 --- 三次消费
ephemeral_out = getattr(agent, "_ephemeral_max_output_tokens", None)
if ephemeral_out is not None:
agent._ephemeral_max_output_tokens = None # consume immediately
五、Token 怎么算的?
5.1 核心公式:字符数 ÷ 4
Hermes 没有使用任何精确 tokenizer(没 tiktoken、没 transformers),全程靠字符数估算:
python
# model_metadata.py:1718-1727 --- 基础估算函数
def estimate_tokens_rough(text: str) -> int:
"""Rough token estimate (~4 chars/token) for pre-flight checks.
Uses ceiling division so short texts (1-3 chars) never estimate as
0 tokens, which would cause the compressor and pre-flight checks to
systematically undercount when many short tool results are present.
"""
if not text:
return 0
return (len(text) + 3) // 4
5.2 消息级估算(含图片特殊处理)
python
# model_metadata.py:1730-1744 --- 消息 token 估算
def estimate_messages_tokens_rough(messages: List[Dict[str, Any]]) -> int:
"""Rough token estimate for a message list (pre-flight only).
Image parts (base64 PNG/JPEG) are counted as a flat ~1500 tokens per
image --- the Anthropic pricing model --- instead of counting raw base64
character length. Without this, a single ~1MB screenshot would be
estimated at ~250K tokens and trigger premature context compression.
"""
_IMAGE_TOKEN_COST = 1500
total_chars = 0
image_tokens = 0
for msg in messages:
total_chars += _estimate_message_chars(msg)
image_tokens += _count_image_tokens(msg, _IMAGE_TOKEN_COST)
return ((total_chars + 3) // 4) + image_tokens
图片部分故意不算 base64 字符串长度:
python
# model_metadata.py:1773-1803 --- _estimate_message_chars 排除 base64
def _estimate_message_chars(msg: Dict[str, Any]) -> int:
"""Char count for token estimation, excluding base64 image data."""
if not isinstance(msg, dict):
return len(str(msg))
shadow: Dict[str, Any] = {}
for k, v in msg.items():
if k == "_anthropic_content_blocks":
continue
if k == "content":
if isinstance(v, list):
cleaned = []
for part in v:
if isinstance(part, dict):
if part.get("type") in {"image", "image_url", "input_image"}:
cleaned.append({"type": part.get("type"), "image": "[stripped]"})
else:
cleaned.append(part)
else:
cleaned.append(part)
shadow[k] = cleaned
elif isinstance(v, dict) and v.get("_multimodal"):
shadow[k] = v.get("text_summary", "")
else:
shadow[k] = v
else:
shadow[k] = v
return len(str(shadow))
5.3 请求级估算(包含系统提示和工具 schema)
python
# model_metadata.py:1806-1827 --- 完整请求 token 估算
def estimate_request_tokens_rough(
messages: List[Dict[str, Any]],
*,
system_prompt: str = "",
tools: Optional[List[Dict[str, Any]]] = None,
) -> int:
"""Rough token estimate for a full chat-completions request.
Includes the major payload buckets Hermes sends to providers:
system prompt, conversation messages, and tool schemas. With 50+
tools enabled, schemas alone can add 20-30K tokens --- a significant
blind spot when only counting messages. Image content is counted
at a flat per-image cost (see estimate_messages_tokens_rough).
"""
total = 0
if system_prompt:
total += (len(system_prompt) + 3) // 4
if messages:
total += estimate_messages_tokens_rough(messages)
if tools:
total += (len(str(tools)) + 3) // 4
return total
5.4 两套系统的配合
方式一:自己估算(发请求前)------字符数 ÷ 4 + 图片固定 1500
方式二:API 返回的真实值(请求成功后)
python
# conversation_loop.py:1595-1611 --- API 返回精确 token 统计
# Track actual token usage from response for context management
if hasattr(response, 'usage') and response.usage:
canonical_usage = normalize_usage(
response.usage,
provider=agent.provider,
api_mode=agent.api_mode,
)
prompt_tokens = canonical_usage.prompt_tokens
completion_tokens = canonical_usage.output_tokens
total_tokens = canonical_usage.total_tokens
usage_dict = {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": total_tokens,
}
agent.context_compressor.update_from_response(usage_dict)
压缩判断时优先用精确值,回退到估算:
python
# conversation_loop.py:3473-3488 --- 精确值 vs 估算
_compressor = agent.context_compressor
if _compressor.last_prompt_tokens > 0:
# Only use prompt_tokens --- completion/reasoning
# tokens don't consume context window space.
# Thinking models (GLM-5.1, QwQ, DeepSeek R1)
# inflate completion_tokens with reasoning,
# causing premature compression. (#12026)
_real_tokens = _compressor.last_prompt_tokens
else:
_real_tokens = estimate_request_tokens_rough(
messages, tools=agent.tools or None
)
为什么只用 prompt_tokens 不加 completion_tokens?思维链模型的 reasoning token 会被算进 completion_tokens,但这些 token 不占下一轮的输入窗口,加了会导致过早压缩。
5.5 完整 token 生命周期
scss
对话开始(加载历史消息)
│
├── 预飞行检查:estimate_request_tokens_rough(messages)
│ = (系统提示 + 所有消息字符数 + 工具schema字符数) ÷ 4
│ + 图片数 × 1500
│ 超过 context_length × 50%?→ 压缩(最多 3 轮)
│
▼
发 API 请求
│
├── API 返回 usage.prompt_tokens(精确值)
│ 存入 compressor.last_prompt_tokens
│ 累加到 session_prompt_tokens
│
▼
工具调用完成,准备下一轮
│
├── 压缩判断:
│ 优先用 last_prompt_tokens(精确)
│ 回退用 estimate_request_tokens_rough(估算)
│ 超过 50% 阈值?→ 压缩
│
▼
下一轮 API 请求...
六、一次完整的 LLM 请求长什么样?
6.1 请求的最终形态
json
{
"model": "glm-5.2",
"messages": [
{"role": "system", "content": "<系统提示>"},
{"role": "user", "content": "..."},
{"role": "assistant", "tool_calls": [...]},
{"role": "tool", "content": "..."},
{"role": "user", "content": "新的用户消息"}
],
"tools": [...],
"max_tokens": 8192,
"stream": true,
"extra_body": {"reasoning": {"effort": "medium"}}
}
调的是 client.chat.completions.create(**api_kwargs):
python
# chat_completion_helpers.py:152-159 --- 最终的 API 调用
if api_mode == "bedrock_converse":
client = _get_bedrock_runtime_client(region)
raw_response = client.converse(**api_kwargs)
else:
request_client = _set_request_client(
agent._create_request_openai_client(...)
)
result["response"] = request_client.chat.completions.create(**api_kwargs)
6.2 messages 数组的组装
对话消息处理
python
# conversation_loop.py:788-878 --- 构建 api_messages
api_messages = []
for idx, msg in enumerate(messages):
api_msg = msg.copy()
# 注入临时上下文到当前用户消息
if idx == current_turn_user_idx and msg.get("role") == "user":
_injections = []
if _ext_prefetch_cache:
_fenced = build_memory_context_block(_ext_prefetch_cache)
if _fenced:
_injections.append(_fenced)
if _plugin_user_context:
_injections.append(_plugin_user_context)
if _injections:
_base = api_msg.get("content", "")
if isinstance(_base, str):
api_msg["content"] = _base + "\n\n" + "\n\n".join(_injections)
# 保留 reasoning_content(多轮推理连续性)
agent._copy_reasoning_content_for_api(msg, api_msg)
# 剥离内部字段
if "reasoning" in api_msg:
api_msg.pop("reasoning")
if "finish_reason" in api_msg:
api_msg.pop("finish_reason")
api_msg.pop("_thinking_prefill", None)
api_messages.append(api_msg)
系统提示组装
系统提示是整个会话最重的一块,由三层拼接而成(system_prompt.py):
python
# system_prompt.py:60-303 --- build_system_prompt_parts 三层架构
def build_system_prompt_parts(agent, system_message=None):
# ── Stable tier ───────────────────────────────────
stable_parts = []
# SOUL.md persona
if soul_content := _r.load_soul_md():
stable_parts.append(soul_content)
# Tool guidance (memory, session_search, skills, kanban...)
tool_guidance = []
if "memory" in agent.valid_tool_names:
tool_guidance.append(MEMORY_GUIDANCE)
if "session_search" in agent.valid_tool_names:
tool_guidance.append(SESSION_SEARCH_GUIDANCE)
if "skill_manage" in agent.valid_tool_names:
tool_guidance.append(SKILLS_GUIDANCE)
# Skills list
skills_prompt = _r.build_skills_system_prompt(...)
# Environment hints, platform hints, model guidance...
# ── Context tier ────────────────────────────────
context_parts = []
if system_message:
context_parts.append(system_message)
# AGENTS.md, .cursorrules...
context_files_prompt = _r.build_context_files_prompt(...)
# ── Volatile tier ────────────────────────────────
volatile_parts = []
# MEMORY.md block
mem_block = agent._memory_store.format_for_system_prompt("memory")
# USER.md block
user_block = agent._memory_store.format_for_system_prompt("user")
# Timestamp + model + provider
timestamp_line = f"Conversation started: {now}"
return {
"stable": "\n\n".join(stable_parts),
"context": "\n\n".join(context_parts),
"volatile": "\n\n".join(volatile_parts),
}
三层的稳定性设计:
| 层 | 包含什么 | 稳定性 |
|---|---|---|
| stable | SOUL.md 人格、工具使用指导、skills 列表、环境/平台提示 | 整个会话不变 |
| context | AGENTS.md / .cursorrules、用户传入的 system_message | 切目录才变 |
| volatile | MEMORY.md、USER.md、外部记忆插件、时间戳 + 模型名 | 随时可能变 |
系统提示每会话只构建一次,之后原样复用:
python
# conversation_loop.py:843-859 --- 系统提示插入
effective_system = active_system_prompt or ""
if agent.ephemeral_system_prompt:
effective_system = (effective_system + "\n\n" + agent.ephemeral_system_prompt).strip()
if effective_system:
api_messages = [{"role": "system", "content": effective_system}] + api_messages
# Inject ephemeral prefill messages
if agent.prefill_messages:
sys_offset = 1 if (api_messages and api_messages[0].get("role") == "system") else 0
for idx, pfm in enumerate(agent.prefill_messages):
api_messages.insert(sys_offset + idx, pfm.copy())
6.3 参数组装
python
# chat_completions.py:408-522 --- _build_kwargs_from_profile(profile 路径)
def _build_kwargs_from_profile(self, profile, model, sanitized, tools, params):
# 基础参数
api_kwargs = {
"model": model,
"messages": sanitized,
}
# Temperature(profile 固定值 或 调用方传入)
if profile.fixed_temperature is OMIT_TEMPERATURE:
pass # 不传 temperature
elif profile.fixed_temperature is not None:
api_kwargs["temperature"] = profile.fixed_temperature
# Timeout
timeout = params.get("timeout")
if timeout is not None:
api_kwargs["timeout"] = timeout
# Tools
if tools:
api_kwargs["tools"] = tools
# max_tokens 优先级:ephemeral > user > profile default
ephemeral = params.get("ephemeral_max_output_tokens")
user_max = params.get("max_tokens")
if ephemeral is not None:
api_kwargs.update(max_tokens_fn(ephemeral))
elif user_max is not None:
api_kwargs.update(max_tokens_fn(user_max))
elif profile.default_max_tokens:
api_kwargs.update(max_tokens_fn(profile.default_max_tokens))
# extra_body 组装(provider preferences, reasoning, thinking...)
extra_body = profile.build_extra_body(...)
# Request overrides(用户 config)
overrides = params.get("request_overrides")
if overrides:
for k, v in overrides.items():
if k == "extra_body" and isinstance(v, dict):
extra_body.update(v)
else:
api_kwargs[k] = v
if extra_body:
api_kwargs["extra_body"] = extra_body
return api_kwargs
6.4 一次请求的 token 构成
scss
prompt_tokens ≈ 系统提示 (~8-15K)
+ 工具 schema (~20-30K)
+ 对话历史 (可变,持续增长)
+ 临时注入 (memory prefetch 等)
+ 图片 (每张 ~1000-2000 token,provider 按像素算)
七、图片处理全流程
用户发送的图片有两种截然不同的处理路径,取决于模型是否支持视觉。
7.1 平台下载:统一模式
所有平台(飞书、Telegram、Discord、Signal...)都遵循同一个模式:先下载到本地,拿到文件路径。
以飞书为例:
python
# feishu.py:3494-3501 --- 下载消息中的图片
for image_key in normalized.image_keys:
cached_path, media_type = await self._download_feishu_image(
message_id=message_id,
image_key=image_key,
)
if cached_path:
media_urls.append(cached_path)
media_types.append(media_type)
python
# feishu.py:3557-3586 --- 通过飞书 API 下载图片
async def _download_feishu_image(self, *, message_id: str, image_key: str):
request = self._build_message_resource_request(
message_id=message_id,
file_key=image_key,
resource_type="image",
)
response = await asyncio.to_thread(self._client.im.v1.message_resource.get, request)
raw_bytes = self._read_binary_response(response)
cached_path = cache_image_from_bytes(raw_bytes, ext=ext)
return cached_path, media_type
下载后存入本地缓存:
python
# gateway/platforms/base.py:574-599 --- cache_image_from_bytes
def cache_image_from_bytes(data: bytes, ext: str = ".jpg") -> str:
if not _looks_like_image(data):
snippet = data[:80].decode("utf-8", errors="replace")
raise ValueError(
f"Refusing to cache non-image data as {ext} "
f"(starts with: {snippet!r})"
)
cache_dir = get_image_cache_dir()
filename = f"img_{uuid.uuid4().hex[:12]}{ext}"
filepath = cache_dir / filename
filepath.write_bytes(data)
return str(filepath)
7.2 路由决策:native vs text
python
# gateway/run.py:7615-7638 --- 图片路由决策
if image_paths:
# Decide routing: native (attach pixels) vs text (vision_analyze pre-run)
_img_mode = self._decide_image_input_mode()
if _img_mode == "native":
# Defer attachment to the run_conversation call site.
pending_native[session_key] = list(image_paths)
else:
# Pre-analyze images via vision_analyze, prepend descriptions
message_text = await self._enrich_message_with_vision(
message_text,
image_paths,
)
决策逻辑(image_routing.py):
python
# image_routing.py:1-32 --- 两种模式的说明
"""Routing helpers for inbound user-attached images.
Two modes:
native --- attach images as OpenAI-style ``image_url`` content parts on the
user turn. Provider adapters (Anthropic, Gemini, Bedrock, Codex,
OpenAI chat.completions) already translate these into their
vendor-specific multimodal formats.
text --- run ``vision_analyze`` on each image up-front and prepend the
description to the user's text. The model never sees the pixels.
In ``auto`` mode:
- If the user has explicitly configured ``auxiliary.vision.provider``,
we assume they want the text pipeline regardless of the main model.
- Otherwise, if the active model reports ``supports_vision=True``,
we attach natively.
- Otherwise (non-vision model, no explicit override), we fall back to text.
"""
7.3 路线 A:native 模式
图片被直接编码为 base64 data URL,嵌入 API 请求:
python
# image_routing.py:298-317 --- _file_to_data_url
def _file_to_data_url(path: Path) -> Optional[str]:
"""Encode a local image as a base64 data URL at its native size.
Size limits are NOT enforced here --- the agent retry loop
shrinks on the provider's first rejection.
"""
try:
raw = path.read_bytes()
except Exception as exc:
logger.warning("image_routing: failed to read %s --- %s", path, exc)
return None
mime = _guess_mime(path, raw=raw)
b64 = base64.b64encode(raw).decode("ascii")
return f"data:{mime};base64,{b64}"
最终消息格式:
python
# image_routing.py:320-385 --- build_native_content_parts
def build_native_content_parts(user_text, image_paths):
image_parts = []
for raw_path in image_paths:
p = Path(raw_path)
data_url = _file_to_data_url(p)
if data_url:
image_parts.append({
"type": "image_url",
"image_url": {"url": data_url},
})
# 文本 + 路径提示
base_text = text or "What do you see in this image?"
path_hints = "\n".join(f"[Image attached at: {p}]" for p in attached_paths)
combined_text = f"{base_text}\n\n{path_hints}"
parts = [{"type": "text", "text": combined_text}]
parts.extend(image_parts)
return parts
最终在 API 请求里,用户消息的 content 从字符串变成数组:
json
{
"role": "user",
"content": [
{"type": "text", "text": "这张图是什么意思?\n\n[Image attached at: /path/to/img.jpg]"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,iVBORw0KGgo..."}}
]
}
base64 太大会怎样?
关键洞见:base64 确实非常长(一张 1MB 图 base64 后约 1.37MB),但不会造成 token 爆炸。 原因有三层防护:
Layer 1 :token 估算时不算 base64 字符数。_estimate_message_chars() 遇到 image_url 部分直接用 "[stripped]" 占位,图片单独计 1500 token。
Layer 2:API provider 也不按文本算图片 token。各家 provider 有自己的图片 token 计算方式(Anthropic 按像素 ÷ 750,OpenAI 按 tiles,Gemini 按 258/tile),都会先把 base64 解码回图片再算。
Layer 3 :压缩时自动剥离历史图片。关键是------只有最新带图消息保留原始像素,旧的都被替换成文字占位符:
python
# context_compressor.py:275-329 --- _strip_historical_media
def _strip_historical_media(messages):
"""Replace image parts in older messages with placeholder text.
The anchor is the LAST user message that has any image content. Every
message before that anchor gets its image parts replaced with a short
placeholder so the outgoing request stops re-shipping the same multi-MB
base-64 image blobs on every turn.
"""
# 找到最后一条带图片的用户消息(锚点)
anchor = -1
for i in range(len(messages) - 1, -1, -1):
msg = messages[i]
if msg.get("role") == "user" and _content_has_images(msg.get("content")):
anchor = i
break
# 锚点之前的图片 → 文字占位符
for i, msg in enumerate(messages):
if i >= anchor:
continue
content = msg.get("content")
if not _content_has_images(content):
continue
new_msg = msg.copy()
new_msg["content"] = _strip_images_from_content(content)
result.append(new_msg)
图片被替换成什么?
python
# context_compressor.py:247-272 --- _strip_images_from_content
def _strip_images_from_content(content: Any) -> Any:
"""Return a copy of content with every image part replaced by a
short text placeholder.
- Image parts become `{"type": "text", "text": "[Attached image ---
stripped after compression]"}`.
"""
for p in content:
if _is_image_part(p):
new_parts.append({
"type": "text",
"text": "[Attached image --- stripped after compression]",
})
else:
new_parts.append(p)
这段逻辑在每次压缩结束时执行:
python
# context_compressor.py:1719-1725 --- 压缩末尾的图片剥离
# Replace image parts in all compressed messages before the newest
# image-bearing user turn with a short text placeholder. Without
# this, tail messages keep their original multi-MB base-64 image
# payloads forever, which can push every subsequent API request
# past the provider's body-size limit and wedge the session.
# Port of Kilo-Org/kilocode#9434.
compressed = _strip_historical_media(compressed)
单张图太大被拒?自动缩小
python
# conversation_compression.py:485-558 --- try_shrink_image_parts_in_messages
def try_shrink_image_parts_in_messages(api_messages: list) -> bool:
"""Re-encode all native image parts at a smaller size to recover from
image-too-large errors (Anthropic 5 MB, unknown other providers).
Strategy: look for image_url / input_image parts carrying a
data:image/...;base64,... payload. For each one whose encoded
size exceeds 4 MB (a safe target that slides under Anthropic's 5 MB
ceiling), write the base64 to a tempfile, call
vision_tools._resize_image_for_vision to produce a smaller data
URL, and substitute it in place.
"""
target_bytes = 4 * 1024 * 1024 # 4 MB target
for msg in api_messages:
if not isinstance(msg, dict):
continue
content = msg.get("content")
if not isinstance(content, list):
continue
for part in content:
ptype = part.get("type")
if ptype not in {"image_url", "input_image"}:
continue
image_value = part.get("image_url")
if isinstance(image_value, dict):
url = image_value.get("url", "")
resized = _shrink_data_url(url)
if resized:
image_value["url"] = resized
changed_count += 1
return changed_count > 0
模型不支持视觉?自动降级
API 返回"不支持图片"错误时,剥离图片,只留文字重试:
python
# conversation_loop.py:1931-2003 --- 检测并剥离不支持的图片
# 检测到这些错误信息:
"image_url is not supported"
"image content is not supported"
"unknown variant `image_url`, expected `text`"
# → 自动重试,移除所有 image_url 部分
# → 返回纯文本消息
7.4 路线 B:text 模式
图片不直接发给模型,而是先用 vision_analyze 工具描述一遍:
python
# gateway/run.py:14189-14245 --- _enrich_message_with_vision
async def _enrich_message_with_vision(user_text, image_paths):
analysis_prompt = (
"Describe everything visible in this image in thorough detail. "
"Include any text, code, data, objects, people, layout, colors, "
"and any other notable visual information."
)
for path in image_paths:
result_json = await vision_analyze_tool(
image_url=path,
user_prompt=analysis_prompt,
)
result = json.loads(result_json)
if result.get("success"):
description = result.get("analysis", "")
enriched_parts.append(
f"[The user sent an image~ Here's what I can see:\n{description}]\n"
f"[If you need a closer look, use vision_analyze with "
f"image_url: {path} ~]"
)
最终 user_message 变成纯文本,含 vision_analyze 工具的描述。
7.5 两条路线对比
| native 模式 | text 模式 | |
|---|---|---|
| 模型看到什么 | 原始像素(base64 data URL) | 文字描述 |
| content 格式 | 数组 [{text}, {image_url}] |
纯字符串 |
| token 估算(Hermes) | 每张图 +1500 token + 文本 | 只有文本 token |
| API 实际 token | provider 按像素/tile 算,~1000-2000 | 只有文本 |
| 网络传输体积 | 图片 base64,较大 | 只有文本,小 |
| 质量 | 模型直接理解像素 | 有损(取决于描述质量) |
| 历史图片处理 | 压缩时自动剥离 | 无需处理 |
| 适用模型 | GPT-4o, Claude 4, Gemini等 | DeepSeek, Kimi, 非视觉模型 |
八、全景:Token 管理的完整架构
把所有机制串起来,Hermes 的 token 管理是一个多层反馈系统:
ini
┌─────────────────────────────────────────────┐
│ 用户发消息 │
│ (可能带图片 → native/text 决策) │
└─────────────────┬───────────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ 系统提示构建(每会话一次,缓存) │
│ stable + context + volatile 三层拼接 │
└─────────────────┬───────────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ 防线 A:预飞行 token 估算 │
│ 公式: (字符串 ÷ 4) + 图片 × 1500 │
│ 阈值: context_window × 50% │
│ 超过 → 压缩(最多 3 轮) │
└─────────────────┬───────────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ 组装 API 请求 │
│ system + 清洗后的 messages + tools │
│ + max_tokens + extra_body + overrides │
│ native 图片:content = [{text}, {image_url}] │
│ text 图片:content = "描述文字" │
└─────────────────┬───────────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ client.chat.completions.create() │
└─────────────────┬───────────────────────────┘
│
┌────────────┴────────────┐
▼ ▼
┌─────────────────┐ ┌─────────────────────┐
│ 成功返回 │ │ API 报错 │
│ usage 精确值 │ │ │
└────────┬────────┘ │ context overflow? │
│ │ → 防线 C:压缩 │
│ │ │
│ │ max_tokens 太大? │
│ │ → 缩减到安全值 │
│ │ │
│ │ 图片不支持? │
│ │ → 剥离图片重试 │
│ │ │
│ │ 图片太大 (>5MB)? │
│ │ → 缩小到 4MB 重试 │
└───────┬───────┴─────────────────────┘
│
▼
┌──────────────────────────┐
│ finish_reason = "stop" │
│ → 正常返回 │
├──────────────────────────┤
│ finish_reason = "length" │
│ → 自动续写(最多 2 轮) │
│ 每轮 max_tokens 递增 │
└──────────────────────────┘
│
▼
┌──────────────────────────┐
│ 防线 B:工具调用后压缩 │
│ 用 API 精确 prompt_tokens │
│ 超 50% → 压缩 │
│ 压缩后 → 剥离历史图片 │
└──────────────────────────┘
附录:关键源码文件索引
| 文件 | 行数 | 关注点 |
|---|---|---|
agent/conversation_loop.py |
4191 | 核心对话循环:截断检测 :1388, 续写 :1501, 压缩触发 :467, :2593, :3490, 消息组装 :788, 系统提示 :462 |
agent/context_compressor.py |
1748 | 上下文压缩器:阈值 :553, 防抖 :613, 执行 :1494, 图片剥离 :275, :247 |
agent/model_metadata.py |
1827 | 模型元数据:context_window 探测 :1429, token 估算 :1718, :1730, :1806 |
agent/system_prompt.py |
346 | 系统提示构建:三层架构 :60, 组装 :287 |
agent/chat_completion_helpers.py |
2097 | API 请求构建:ephemeral 覆盖 :257, :413, :447, 调用 :159 |
agent/transports/chat_completions.py |
629 | 传输层:build_kwargs :175, profile 路径 :408 |
agent/agent_init.py |
1400+ | Agent 初始化:max_tokens 默认 :458, config 覆盖 :1228 |
agent/image_routing.py |
391 | 图片路由:模式决策, data URL 编码 :298, content parts 构建 :320 |
agent/conversation_compression.py |
603 | 图片缩小重试:try_shrink_image_parts_in_messages :485 |
run_agent.py |
4246 | AIAgent 类定义:_max_tokens_param() :971, model 切换 :969 |
gateway/run.py |
18207 | Gateway 主循环:图片路由 :7615, vision enrich :14189 |
gateway/platforms/feishu.py |
5058 | 飞书平台:图片下载 :3557, 消息资源 :3494 |
gateway/platforms/base.py |
3813 | 平台基类:cache_image_from_bytes :574, cache_image_from_url :602 |
本文档基于 Hermes Agent 源码逐行阅读整理,所有代码引用均标注文件名和行号,可对照源码验证。