1. 为什么 Agent 里的 Prompt 不能是一段写死的字符串
很多人第一次接触 Prompt,都是从一段固定文本开始的:你是一名资深工程师,请分析下面这段代码,找出问题并修复,要求保持功能不变、补充测试、给出说明。这种写法在单轮问答里非常好用,因为任务边界清晰,模型看完就能给答案。
但一旦把模型放进 Agent 的执行循环,这套思路立刻失效。一个 Coding Agent 要修一个 Bug,得先理解项目结构、读相关代码、调搜索工具定位问题、改多个文件、跑测试,再根据失败结果调整方向。每一轮模型面对的问题都不一样,最初那段固定 Prompt 根本覆盖不了整段任务。
Agent 真正依赖的,是一份随着任务推进不断重新组装出来的上下文。这就是动态 Prompt 拼装要解决的问题。而拼装出来的东西,本质上已经不是一段文字,而是一个有明确输入、可单独维护、可被测试的系统组件。
这篇就聚焦一件事:怎么用 TaoToken 的统一 Key 和 API 通道做接入层,把 Prompt 模板、上下文注入、运行时变量拼装封装成一个可复用的系统组件。我会给出 config.toml 和 settings.json 的骨架配置、组件目录结构,并完整演示一次从上下文注入到请求发出的验证动作。适合正在写 Agent、被上下文膨胀和 Prompt 维护折磨的开发者。
2. 动态 Prompt 的四类信息与拼装顺序
在动手写组件之前,先把要拼的东西分清楚。一次 Agent 调用的输入,通常不是用户某条单独消息,而是按结构拼出来的上下文:System Prompt + 任务目标 + 当前状态 + 相关上下文 + 工具信息 + 历史结果 + 当前行动要求。
这些信息的生命周期差别很大,可以归成四类:
稳定信息贯穿整个任务,包括 Agent 身份、行为规则、安全限制、输出格式,一旦确定基本不变。任务信息跟一次任务绑定,任务结束就消失,比如“修复登录接口的 Token 过期问题”。状态信息随执行不断刷新,定位阶段可能是“auth.py 里存在 Token 判断逻辑”,改完代码后变成“已修改 auth.py,测试用例失败”。外部信息是按需加载的临时内容,某个文件内容、一次搜索结果、一段文档、一次工具返回,用完就可以移出上下文腾空间。
拼装的核心动作,就是在每次模型调用前判断:这四类信息里哪些该进当前输入,哪些不该进。
这里有个和成本直接相关的细节:拼装顺序。现在主流推理服务大多支持 Prefix Cache,相同开头前缀可以复用计算结果。稳定信息每步都不变,把它们固定放在最前面,让变化的状态和工具返回排在后面,前缀就能稳定命中缓存,省下延迟和费用。反过来,如果每轮都改动开头,缓存整段失效,成本立刻上去。
所以组件的拼装顺序建议固定为:system → rules → tools → goal → state → context → history → action。前四段尽量稳定,后四段随运行时变化。
3. TaoToken 接入层:统一 Key 与通道准备
动态 Prompt 组件要真正发出请求,需要一个稳定的接入层。我用 TaoToken 做这一层,原因是它把 Key 和 API 通道统一了,组件里不用关心底层换模型、换通道的细节,只认一个 base_url 和一把 Key。
先拿到访问凭证。打开控制台创建 API Key:
控制台地址:https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite
创建完 Key 之后,接入文档在这里,里面有各语言的调用示例和参数说明:
接入文档:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite
API 的基础地址是https://taotoken.net/api,注意这个地址不带任何查询参数,直接作为 OpenAI 兼容的 base_url 使用即可。如果你用的是 Anthropic 风格的接口,走 ClaudeCodeAnthropic 这条通道:
ClaudeCodeAnthropic:https://taotoken.net/ClaudeCodeAnthropic?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite
Key 不要硬编码进代码,统一走环境变量或配置文件。下面两节给出骨架。
4. 组件目录结构与 config.toml 骨架
先规划目录。把 Prompt 拆成可管理的模块,运行时按当前任务组合,这是落地时最省心的做法:
agent_prompt/ ├── config.toml # 组件级配置:模型、通道、缓存策略 ├── settings.json # 运行时变量与模板映射 ├── templates/ │ ├── system.md # 稳定信息:身份、规则、安全限制 │ ├── rules.md # 稳定信息:行为约束 │ ├── tools.md # 稳定信息:工具描述 │ ├── task_template.md # 任务信息:目标模板 │ └── action.md # 当前行动要求 ├── builder.py # 拼装逻辑 └── tests/ └── test_builder.py # 组件单测config.toml负责组件级配置,把接入层参数和拼装策略都放这里:
[provider] base_url = "https://taotoken.net/api" api_key_env = "TAOTOKEN_API_KEY" default_model = "claude-sonnet-4-5" timeout_seconds = 60 [prompt] # 稳定前缀顺序固定,保证 Prefix Cache 命中 stable_order = ["system", "rules", "tools"] dynamic_order = ["goal", "state", "context", "history", "action"] template_dir = "templates" [context] max_context_tokens = 6000 drop_policy = "fifo" # 外部信息超出预算时按先进先出移除 keep_recent_turns = 4 # 历史结果只保留最近 4 轮settings.json负责运行时变量与模板映射,把“哪个变量填进哪个模板”这件事显式声明出来:
{ "template_map": { "system": "system.md", "rules": "rules.md", "tools": "tools.md", "goal": "task_template.md", "action": "action.md" }, "runtime_vars": { "agent_name": "code-maintainer", "project_type": "python-web", "output_format": "markdown" }, "state_slots": ["current_stage", "found_files", "last_error"], "context_slots": ["retrieved_files", "tool_results"] }这两个文件的分工要清楚:config.toml管的是组件怎么连、怎么拼、预算多少;settings.json管的是这次运行填什么值、哪些槽位是状态、哪些是外部信息。改行为改 config,改数据改 settings,互不污染。
5. builder.py:把模板渲染成一次请求
拼装逻辑本质是一次模板渲染。下面这个builder.py是可直接跑的最小实现,核心是把稳定段和动态段分开处理,稳定段拼成前缀,动态段按预算裁剪:
import json import os import tomllib from pathlib import Path from openai import OpenAI BASE = Path(__file__).parent def load_config(): with open(BASE / "config.toml", "rb") as f: return tomllib.load(f) def load_settings(): with open(BASE / "settings.json", "r", encoding="utf-8") as f: return json.load(f) def render_template(name: str, variables: dict) -> str: path = BASE / "templates" / name text = path.read_text(encoding="utf-8") for key, value in variables.items(): text = text.replace("{{" + key + "}}", str(value)) return text def build_prompt(state: dict, context: dict, history: list) -> str: cfg = load_config() settings = load_settings() tmap = settings["template_map"] variables = {**settings["runtime_vars"], **state} segments = [] # 稳定前缀:顺序固定,保证 Prefix Cache 命中 for key in cfg["prompt"]["stable_order"]: segments.append(render_template(tmap[key], variables)) # 任务目标 segments.append(render_template(tmap["goal"], variables)) # 状态信息 state_block = "\n".join( f"- {slot}: {state.get(slot, 'N/A')}" for slot in settings["state_slots"] ) segments.append("## 当前状态\n" + state_block) # 外部信息:按预算裁剪 budget = cfg["context"]["max_context_tokens"] keep_turns = cfg["context"]["keep_recent_turns"] ctx_lines = [] for slot in settings["context_slots"]: for item in context.get(slot, []): ctx_lines.append(f"[{slot}] {item}") trimmed = ctx_lines[-budget // 50:] if len(ctx_lines) > budget // 50 else ctx_lines segments.append("## 相关上下文\n" + "\n".join(trimmed)) # 历史结果:只保留最近 N 轮 recent = history[-keep_turns:] hist_block = "\n".join(f"{h['role']}: {h['content']}" for h in recent) segments.append("## 历史结果\n" + hist_block) # 当前行动要求 segments.append(render_template(tmap["action"], variables)) return "\n\n".join(segments) def call_model(prompt: str) -> str: cfg = load_config() client = OpenAI( base_url=cfg["provider"]["base_url"], api_key=os.environ[cfg["provider"]["api_key_env"]], timeout=cfg["provider"]["timeout_seconds"], ) resp = client.chat.completions.create( model=cfg["provider"]["default_model"], messages=[{"role": "user", "content": prompt}], ) return resp.choices[0].message.content几个关键点值得说明。稳定段用stable_order循环渲染,顺序写死在 config 里,任何人改模板都不会打乱前缀。外部信息用budget // 50做粗略的条数上限,真实项目里可以换成 tokenizer 精确计数。历史结果只取最近keep_recent_turns轮,这就是“做减法”的落点。
templates/system.md里放稳定信息,比如:
你是 {{agent_name}},负责维护 {{project_type}} 项目。 修改代码前必须先理解现有结构。 完成修改后必须运行测试。 输出格式统一为 {{output_format}}。templates/action.md放当前行动要求:
## 当前行动 基于以上状态和上下文,给出下一步的具体操作。 如果需要更多信息,明确说明需要哪个文件或哪次工具调用。6. 验证:一次上下文注入到请求发出
配置和代码就位后,跑一次完整验证。先设置环境变量:
export TAOTOKEN_API_KEY="你的Key"然后写一个验证脚本,模拟 Agent 第二轮调用的状态:
from builder import build_prompt, call_model state = { "current_stage": "locate", "found_files": "auth/login.py", "last_error": "expired token treated as valid", } context = { "retrieved_files": [ "auth/login.py: validate_token() 位于第 42 行", "auth/login.py: 过期判断使用 < 而非 <=", ], "tool_results": [ "grep 'validate_token' -> auth/login.py:42", ], } history = [ {"role": "user", "content": "修复登录接口 Token 过期问题"}, {"role": "assistant", "content": "已定位到 auth/login.py"}, ] prompt = build_prompt(state, context, history) print("=== 拼装后的 Prompt ===") print(prompt) print("\n=== 模型返回 ===") print(call_model(prompt))运行后你会看到拼装结果的结构:最前面是 system、rules、tools 三段稳定前缀,接着是任务目标、当前状态、相关上下文、历史结果、当前行动。模型返回会针对validate_token()的边界判断给出具体修改建议。
验证成功的标志有三个:一是拼装结果里稳定段顺序和 config 里stable_order完全一致;二是外部信息只保留了预算内的条目,没有把全部历史塞进去;三是请求正常返回,说明 TaoToken 的 base_url 和 Key 配置正确。
如果你想先确认模型通道本身是通的,可以到模型对话页面直接发一条消息试试:
模型对话:https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite
7. 本篇常见错排查
报 401 或鉴权失败。先确认TAOTOKEN_API_KEY环境变量在当前 shell 里真的存在,echo $TAOTOKEN_API_KEY看一下。常见坑是 Key 写进了.env但脚本没加载,或者 Key 前后带了空格。另外确认 base_url 是https://taotoken.net/api,不要手动拼多余的路径。
模板变量没被替换。检查settings.json里runtime_vars的键名和模板里{{ }}里的名字是否完全一致,大小写敏感。state里的槽位如果没在state_slots声明,也不会进状态块。
上下文还是太长。max_context_tokens只是条数上限的粗略换算,真实项目建议接入 tokenizer 精确计数。另外检查keep_recent_turns是不是设得太大,历史结果是最容易膨胀的部分。
Prefix Cache 没命中,费用偏高。八成是稳定段顺序被打乱了,或者有人在 system 模板里塞了运行时变量。稳定段里只放长期不变的内容,任何随任务变化的值都应该挪到动态段。
组件单测怎么写。tests/test_builder.py里断言拼装结果的段落顺序和 config 一致即可,不需要真的调模型。把call_model和build_prompt分开,就是为了让拼装逻辑可以脱离网络单独测试。
8. 把接入层和组件层分开,长期编码更省心
动态 Prompt 做成系统组件之后,你会发现维护成本大幅下降:改行为改模板,改预算改 config,改数据改 settings,三层互不干扰。而接入层用 TaoToken 统一 Key 和通道,组件里只认一个 base_url,换模型、调通道都不用动拼装逻辑。
如果你在写长期运行的 Coding Agent,或者需要多轮工具调用的场景,建议把接入凭证和额度规划单独管理,Coding Plan 适合这种持续编码的用法:
Coding Plan:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite
Key 的创建和管理都在控制台完成,接入细节查文档就够:
API Keys:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite 接入文档:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite
最后留一个我踩过的坑:一开始我把state和context混在一个字典里传,结果状态槽位被当成外部信息按 FIFO 裁掉了,模型第二轮就丢了当前阶段。后来严格按state_slots和context_slots分开声明,裁剪只作用于外部信息,状态永远保留,问题就没了。组件化的价值就在这——出问题的时候,你知道该去哪个文件里找。