前言
上一篇 主要讲了下项目背景和整体构思。这一章废话少说,撸起袖子就是干。
.env
和以往一样,我们项目涉及的 LLM 相关 api_key 等信息都不会硬编码到代码中。而是放在环境文件中。
Python
# LLM API Configuration
LLM_APP_KEY = "你的LLM API Key"
LLM_BASE_URL = "你的LLM服务URL"
# MCP Tool Configuration
MCP_APP_ID = "你的MCP工具ID"
# LangSmith Configuration (Optional)
# LANGSMITH_API_KEY = "你的LangSmith API"
# Available Models Configuration (JSON format)
# Format: [{"model_id":"","display_name":"","icon":"Zap or Cpu","icon_color":"tailwind_color_class"}]
AVAILABLE_MODELS=[{"model_id":"qwen3.6-flash","display_name":"Qwen-Flash","icon":"Zap","icon_color":"yellow-400"},{"model_id":"qwen3.6-plus","display_name":"Qwen-Plus","icon":"Zap","icon_color":"orange-400"},{"model_id":"qwen3.7-max","display_name":"Qwen-Max","icon":"Cpu","icon_color":"purple-400"}]
# Web Search Rate Limiting Configuration
WEB_SEARCH_MAX_QPS = 12
# Research Configuration
NUMBER_OF_INITIAL_QUERIES = 2
MAX_RESEARCH_LOOPS = 2
LLM
该模块主要封装客户端与 LLM 的通讯。其实很简单,我们在过往各种 demo 中已经在熟悉不过了。
ModelConfig
定义模型相关配置,在 LLM 调用中唯一确认使用的是哪家的哪个 LLM。
Python
class ModelConfig(BaseModel) :
"""LLM模型配置"""
id : str = Field(..., description="模型ID")
name : str = Field(..., description="模型名称")
icon :str = Field(default="Zap", description="图标类型(Zap/Cpu)")
icon_color : str = Field(default="yellow-400", description="图标颜色")
加载可用模型
前面说了涉及 LLM 的 api_key存储在环境文件中。这里就需要从环境变量加载可用模型。这里一定要注意兜底模型的配置,的那个环境变量未配置时,代码不能因此而 block。
Python
def load_available_models_from_env() :
"""从环境变量加载可用模型"""
model_json = os.getenv(AVAILABLE_MODELS)
if not model_json :
# 兜底模型
return [
ModelConfig(id="qwen3.6-flash", name="Qwen-Flash", icon="Zap", icon_color="yellow-400"),
ModelConfig(id="qwen3.6-plus", name="Qwen-Plus", icon="Zap", icon_color="green-400"),
ModelConfig(id="qwen3.7-max", name="Qwen-Max", icon="Cpu", icon_color="blue-400")
]
try:
models_data = json.loads(model_json)
return [ModelConfig(**model) for model in models_data]
except Exception as e :
print(f"警告: 解析AVAILABLE_MODELS失败,使用默认模型列表。错误: {e}")
return [
ModelConfig(id="qwen3.6-flash", name="Qwen-Flash", icon="Zap", icon_color="yellow-400"),
ModelConfig(id="qwen3.6-plus", name="Qwen-Plus", icon="Zap", icon_color="green-400"),
ModelConfig(id="qwen3.7-max", name="Qwen-Max", icon="Cpu", icon_color="blue-400")
]
默认模型
Python
def get_default_model_id() :
"""获取默认模型ID(模型列表的最后一项)"""
models = load_available_models_from_env()
if models :
return models[-1].id
return "qwen3.7-max" # 兜底默认值
Agent
Agent 负责承接上层具体的业务,封装 LLM 并将提示词传给 LLM。同时也负责对 LLM 返回的数据进行处理。对 LLM 核心调用在于 step方法传入提示词和其他关键信息。
Python
class Agent :
step_prompt = """{prompt}"""
def __init__(self, model_id=get_default_model_id()) :
self.llm = OpenAICompatibleLLM(model_id=model_id)
def __call(self, prompt) :
response = self.llm.generate_response(prompt)
return response
def set_step_prompt(self, prompt):
self.step_prompt = prompt
def step(self, **kwargs):
step_prompt = self.prompt_format(self.step_prompt, **kwargs)
response = ""
for _ in range(3) :
try:
response = self(step_prompt)
response = self.post_process(response)
break
except Exception as e :
logger.error(f"大模型调用错误:{e}\n{traceback.format_exc()}")
continue
return response
def post_process(self, response):
return response
Prompt 模板渲染
注意,这里主要完成 kwargs关键参数到 prompt {xxx}占位符的高效替换。
Python
def prompt_format(self, prompt, **kwargs) :
"""高效进行 Prompt 模板渲染的基础方式"""
# 深拷贝原prompt,避免污染外部该变量
prompt_ = copy.deepcopy(prompt)
# kwargs:关键参数包,eg:input="Hello", style="Formal";遍历参数包读值
for k in kwargs.keys() :
# 构造占位符,并将占位符(key)对应的值修改为参数包传过来的值,完成prompt模板的渲染
rep = "{" + k + "}"
prompt_ = prompt_.replace(rep, str(kwargs[k]))
return prompt_
JsonAgent
Agent 子类。主要负责将 LLM 返回数据转换为 JSON 根式。核心代码在post_process方法。
Python
class JsonAgent(Agent):
"""将LLM返回数据转换为JSON根式"""
def __init__(self, model_id=get_default_model_id(), keys=None):
super().__init__(model_id)
self.keys = keys
def post_process(self, response):
"""self.keys参数转换为Pydantic模型类"""
result = json.loads(jsonUtils.extract_pattern(response, pattern="json"))
if not self.keys:
return result
# **result:字典解包,将result "炸开"成 key=value 的形式
# self.keys:Pydantic 模型类工厂函数,相当于使用result进行构造;Pydantic自动做字段验证和类型转换
return self.keys(**result)
限流器
什么是限流器? 主流的 LLM 对外部请求都会做限流限制。比如 1s 内连续 N 次请求会触发 429 错误。所以我们在请求 LLM 时,要做一个限流器用于控制自己发起的 LLM 请求,以减少触发 429 错误的概率。我们目前实现的 RateLimiter 基于令牌桶算法。
令牌桶算法是网络流量整形(Traffic Shaping)和速率限制(Rate Limiting)中最经典的算法之一。
-
1. 有一个桶 :系统以恒定的速率(如你的
max_qps=15)向桶里放入"令牌"。 -
2. 令牌满了就溢出:桶有最大容量(Burst Size)。如果桶满了,新来的令牌会被丢弃。
-
3. 请求拿令牌:每当一个请求进来,必须从桶里拿走一个令牌。
-
4. 没令牌就等待/拒绝:如果桶空了,请求就必须等待,直到有新的令牌生成,或者直接被拒绝。
-
本质:允许短期突发的平均速率限制器。长期速率趋近于
rate = 令牌生成速率,但瞬间可以处理burst = 桶容量个请求。
一个🌰:门一直开着(非阻塞),但是有个保安(令牌桶算法)在发准入手环(令牌)。手环以固定速率(每66ms一个)生成,放在一个盒子里(桶)。
-
果盒子里有10个手环(桶容量=10),来了10个人,瞬间全部拿走手环,同时进门
-
第11个人来的时候,盒子空了,他必须等到下一个手环生成(66ms后)才能拿着手环进去。
Python
class TokenBucketRateLimiter:
"""
基于标准令牌桶算法的速率限制器
支持突发流量,用于控制API请求频率,避免触发429错误
"""
def __init__(self, max_qps: float = 15.0, burst_capacity: float | None = None):
"""
初始化令牌桶速率限制器
:param max_qps: 令牌生成速率(每秒请求数)
:param burst_capacity: 桶容量(最大突发请求数),默认等于max_qps,即允许1秒内的全部突发
"""
self.max_qps = max_qps
self.burst_capacity = burst_capacity if burst_capacity is not None else max_qps
# 初始桶是满的,支持启动阶段突发
self.current_tokens = float(self.burst_capacity)
# 用单调时间,避免系统时钟调整导致的限流失效
self.last_refill_time = time.monotonic()
self.lock = asyncio.Lock()
logger.info(
f"令牌桶速率限制器初始化:QPS={max_qps}, "
f"桶容量={self.burst_capacity}, "
f"单令牌生成间隔={1.0 / max_qps:.3f}秒"
)
async def acquire(self) -> float:
"""
阻塞式获取请求许可,若令牌不足则等待至有足够令牌
:return: float 实际等待时间(秒)
"""
wait_time = 0.0
async with self.lock:
now = time.monotonic()
# 1. 惰性填充令牌:计算从上次填充到现在的新增令牌数
# 等待的时间
elapsed = now - self.last_refill_time
# 等待时间新增的令牌数
new_tokens = elapsed * self.max_qps
# 当前令牌数更新
self.current_tokens = min(self.burst_capacity, self.current_tokens + new_tokens)
# 上次更新时间
self.last_refill_time = now
# 2. 检查是否有足够令牌
if self.current_tokens >= 1.0:
# 令牌足够一次请求,放行当前请求,令牌数减1
self.current_tokens -= 1.0
else:
# 3. 令牌不够一次请求。计算需要等待的时间:补上缺口所需的时长
# 还需要多少令牌才够一次请求
deficit = 1.0 - self.current_tokens
# 达到这些令牌数需要等待的时间
wait_time = deficit / self.max_qps
# 提前更新填充时间为未来时间,避免重复计算;这个时间后至少可满足一次请求
self.last_refill_time = now + wait_time
self.current_tokens = 0.0 # 等待后刚好消耗1个令牌,剩余0
# 关键:锁外睡眠,避免长时间占用锁影响并发
if wait_time > 0:
logger.debug(f"速率限制,需要等待{wait_time:.3f}秒")
await asyncio.sleep(wait_time) # 使用 await
return wait_time
def try_acquire(self) -> bool:
"""
非阻塞式获取请求许可,若令牌不足立即返回False
:return: bool 是否成功获取许可
"""
with self.lock:
now = time.monotonic()
# 惰性填充令牌
elapsed = now - self.last_refill_time
new_tokens = elapsed * self.max_qps
self.current_tokens = min(self.burst_capacity, self.current_tokens + new_tokens)
self.last_refill_time = now
if self.current_tokens >= 1.0:
self.current_tokens -= 1.0
return True
return False
def get_remaining_tokens(self) -> float:
"""返回当前剩余令牌数,仅用于监控调试"""
with self.lock:
return self.current_tokens
重试机制
因为有限流就会有重试,但是不能立刻盲目地重试。针对不同的错误要采取不同的重试策略。一言以蔽之就是:「错误分类→退避决策→自适应调整→熔断兜底」
-
错误分级过滤:
- 永久错误(Permanent):API Key无效、内容合规拦截、参数非法 → 直接终止,禁止重试。
- 瞬时错误(Transient):429(限流)、500/502/503(服务抖动)、网络超时 → 允许重试。
-
差异化退避策略:
- 优先读取LLM响应头的
Retry-After(服务商明确告知的恢复时间,准确率100%),无则返回时采用 「指数退避+随机抖动」。 - 区分限流类型:
- RPM(请求数)超限退避系数设为
5 * 2^attempt - TPM(Token数)超限先降级请求(减少
max_tokens/拆分请求)再退避。
- RPM(请求数)超限退避系数设为
- 优先读取LLM响应头的
-
自适应限流联动 :连续2次触发429,动态下调本地令牌桶的
max_qps(如降至原值的50%),直到连续10次请求成功再恢复,从源头减少重试概率。 -
成本熔断:累计重试 Token 成本超过单次请求阈值(如原请求的2倍)时直接终止,避免无效烧钱
上面提到了 「指数退避+随机抖动」,那么具体是指什么呢?
- 指数退避:一种逐步拉长重试间隔的策略,公式为:
delay = base_delay * (2 ^ attempt)- base_delay:基础等待时间(LLM场景推荐5s起步,远大于普通API的1s,因为LLM服务商恢复周期更长)
- attempt:重试次数(第1次重试attempt=0,第2次=1,以此类推)
- 效果:第1次等5s,第2次等10s,第3次等20s,避免短时间内高频重试冲击服务端。
- 随机抖动:防止指数退避引起惊群效应 。在计算结果上叠加随机因子,把重试时间打散,公式为:
delay = base_delay * (2 ^ attempt) * random.uniform(1-jitter, 1+jitter)- 惊群效应:100个客户端同时触发429,都会在第5s、10s、20s整点重试,再次集中打爆服务端,陷入「限流→重试→更严厉限流」的死循环。
- jitter:抖动系数(LLM场景推荐0.3~0.5,即±30%~50%的波动范围)
- 效果:原本统一的5s等待,会变成3.5s~6.5s之间的随机值,错开所有客户端的重试时间。
我们这里的代码并没有实现复杂的重试机制,只是简单的线性退避。
WebSearchAgent
MCPAgent 子类。主要实现和 WebSearch MCP 的通讯。
Python
class WebSearchAgent(MCPAgent):
"""WebSearch MCP"""
async def astep(self, prompt, **kwargs):
try:
step_prompt = self.step_prompt.format(prompt=prompt)
except Exception as e:
step_prompt = self.step_prompt
api_key = os.getenv("LLM_API_KEY")
app_id = os.getenv("MCP_APP_ID")
# 获取速率限制器; 防止触发限流
rate_limiter = get_web_search_rate_limiter()
for attempt in range(3):
try:
# 在发送请求前进行速率限制检查
wait_time = await rate_limiter.acquire()
if wait_time > 0 :
logger.debug(f"速率限制等待:{wait_time:.3f}秒")
# 获取到令牌后,再切换到线程池执行阻塞的 HTTP 请求
# 此时当前协程让出 CPU,但 rate_limiter 的锁已释放,其他协程可申请令牌
response = await asyncio.to_thread(
Application.call,
api_key=api_key,
app_id=app_id,
prompt=step_prompt,
biz_params=kwargs,
)
response = self.extract_pages_from_mcp_response(response, None)
return response
except Exception as e:
error_msg = str(e)
logger.error(f"Web搜错错误(尝试{attempt + 1} / 3):{e}\n{traceback.format_exc()}")
# 我们本身在请求时已经有了 rate_limiter 为什么还需要判断 MCP_ERROR_RATE_LIMIT 错误
# 自定义 rate_limiter 只是能减少触发限流的可能;但是平台(阿里等)内部的限流机制我们并不清楚,所以采取双保险机制
if "429" in error_msg :
# 递增等待时间:5秒、10秒、15秒
wait_time = 5 * (attempt + 1)
logger.warning(f"检测到 429 错误,等待 {wait_time} 秒后重试...")
time.sleep(wait_time)
# 非429错误,短暂等待后重试
elif attempt < 2:
time.sleep(2)
continue
return None
多路探测防御性解析
如果我们使用 MCP 使用得多了,就可能会发现 MCP 返回的数据结构不同的厂商可能有所差异。这就导致数据解析中也不可能是一成不变的,为了解决这种工具调用返回结构的"不确定性"问题,这里写了一个函数用于多路探测防御性解析返回数据。
- 使用预定义的 JSONPath 列表递归解析
- 使用抽象归一的解析函数代替分散的if/else代码,几种统一
- 扩展性友好,后续新增路径只需要更新预定义的 JSONPath 列表即可
Python
def extract_pages_from_mcp_response(self, response, config=None):
"""从MCP WebSearch响应中提取pages数据(重构版)
Args:
response: MCP响应对象
config: 解析路径配置,默认使用预定义的JSONPath列表
"""
if response is None:
raise Exception("Web搜索结果不正确")
if not response.status_code == 200:
raise Exception(f"Web搜索异常: {response.status_code}")
# 定义兜底的解析scheme 优先级从高到低
# 多路探测防御性解析,解决MCP等工具调用返回结构的"不确定性"问题
DEFAULT_PARSE_CONFIG = {
"paths": [
"$.pages", # 直接在第一层
"$.data.pages", # data路径
"$.result.content[0].text", # result.content路径
"$.choices[0].message.content",
# 未来新增路径只需加在这里,无需改逻辑
]
}
parse_config = config if config else DEFAULT_PARSE_CONFIG
try:
# 假设 response.output.text 是原始的JSON字符串
first_level = json.loads(response.output.text)
except json.JSONDecodeError as e:
logger.error(f"JSON解析失败: {e}, 原始响应: {response.output.text[:500]}")
raise Exception(f"Web搜索结果JSON解析失败: {str(e)}")
pages = None
matched_path = None
# 循环遍历JSONPath进行匹配
for path_str in parse_config["paths"]:
try:
# 编译表达式(生产环境建议缓存此对象)
jsonpath_expr = jsonpath_parser.parse(path_str)
matches = [match.value for match in jsonpath_expr.find(first_level)]
if matches:
# 取第一个匹配项
candidate = matches[0]
# 特殊处理:如果路径指向的是字符串(如 content[0].text),则再次解析
if isinstance(candidate, str):
try:
candidate = json.loads(candidate)
# 二次解析后,如果里面还有pages,需要再次查找
# 简单处理:如果二次解析后是dict且包含pages,则替换
if isinstance(candidate, dict) and "pages" in candidate:
pages = candidate["pages"]
matched_path = path_str + " -> inner.pages"
break
# 如果二次解析后直接是list,也可能是我们要的数据
elif isinstance(candidate, list):
pages = candidate
matched_path = path_str + " -> inner.list"
break
except json.JSONDecodeError:
# 如果不是JSON字符串,忽略该路径
continue
# 如果直接找到了列表
elif isinstance(candidate, list):
pages = candidate
matched_path = path_str
break
except JSONPathError as e:
logger.warning(f"JSONPath语法错误或匹配失败: {path_str}, Error: {e}")
continue
except (KeyError, IndexError, TypeError) as e:
# 路径存在但索引越界等情况,继续尝试下一条路径
continue
# 后置校验与错误处理
if pages is None:
logger.error(
f"无法从响应中提取pages数据。尝试的路径: {parse_config['paths']}。"
f"响应结构: {json.dumps(first_level, ensure_ascii=False)[:500]}"
)
raise Exception("无法从Web搜索结果中提取页面数据")
if not isinstance(pages, list):
logger.error(f"pages不是列表类型: {type(pages)}, 匹配路径: {matched_path}")
raise Exception("Web搜索结果格式错误")
logger.info(f"成功从路径 '{matched_path}' 提取到 {len(pages)} 条pages数据")
# 数据清洗 目标-"垃圾进,精品出",防止-"Garbage in,Garbage out"
processed_pages = []
for page in pages:
if isinstance(page, dict):
processed_pages.append({
"snippet": page.get("snippet", ""),
"title": page.get("title", ""),
"url": page.get("url", "")
})
return processed_pages
WebSearch MCP
在以上代码我们也发现会用到 MCP_APP_ID 等信息。这是哪里来的呢?这个 id 来源于我们使用的哪个厂商的 MCP 工具。以我们项目为🌰,WebSearch MCP 使用的是阿里百炼平台的网络搜索 MCP。


展望
至此,我们完成了Agent 基础的相关代码。包括 LLM 封装,Prompt 渲染,Agent 类封装等。后面有机会,我们继续用 Graph 实现主流程。