把模型切换交给平台:用蓝耘智能路由搭建商品评论分析工具

做商品评论分析的工具,最烦的其实不是模型效果,而是模型名字。

我手头这个需求很典型:给一批电商评论做三件事------判断情感倾向、抽取商品属性标签、对差评生成一句简短摘要。一开始我图省事,三个任务全写死同一个模型:

python 复制代码
MODEL = "some-chat-model"

跑起来没问题,但很快就不对劲了。情感判断这种二分类任务,用小模型又快又便宜;摘要生成需要点语言组织能力,小模型出来的东西经常是"质量不好,不满意"这种评论原文的复读;标签抽取又对结构化输出要求高。三个任务塞进一个模型,要么贵,要么差。

于是我改成一个任务一个模型,在代码里维护一张映射表:

python 复制代码
TASK_MODEL_MAP = {
    "sentiment": "model-a",
    "tags": "model-b",
    "summary": "model-c",
}

这张表就是新的麻烦。模型会下线、会涨价、会有新版本,每次变动都得改代码、重新发版。更尴尬的是,这张表是我根据当时的测试拍的,过两周自己都记不清为什么 tags 用了 model-b。

这次我换了个思路:代码里只写任务名,实际用哪个模型交给平台的智能路由去决定。下面把整个过程拆开讲,包括我实际验证过的部分和没验证的部分。

先想清楚"交给平台"到底交出去什么

"智能路由"这个词听起来很玄,但落到工程上,它要解决的其实就是一件事:调用方表达意图,平台决定实现。

调用方说"我要做情感判断",平台根据任务类型、当前可用模型、成本策略、甚至是负载情况,选一个模型执行。调用方不需要知道最后是谁干的。

这里有个前提必须说清楚:路由规则本身是平台侧的能力,不是我能完全控制的。我能控制的是怎么把"任务意图"表达清楚,以及在拿到结果后怎么处理。

所以我的设计重点放在两件事上:

  1. 代码里彻底不出现具体模型名,只出现任务标识。
  2. 对返回结果做防御性解析,因为不同模型的实际输出格式可能有差异------这一点后面会展开。

【注意】蓝耘平台智能路由的具体配置入口、规则优先级、以及是否支持按任务名自定义路由,本文不做断言。我下面写的调用层是围绕"任务名 → 平台路由 → 模型返回"这个抽象来设计的,具体参数请以你所用平台的当前文档为准。我没验证过的部分会明确标出来。

把调用层抽象成一个函数

核心思路是:不管什么任务,对外只暴露一个函数。

python 复制代码
# router_client.py
# Python 3.10+
# 说明:这里用 requests 直连,实际 SDK 名称和参数请以平台文档为准

import os
import json
import requests

BASE_URL = os.environ.get("LANYUN_BASE_URL", "")   # 平台提供的接口地址
API_KEY = os.environ.get("LANYUN_API_KEY", "")

def call_task(task_name: str, payload: dict, timeout: int = 30) -> dict:
    """
    task_name: 任务标识,比如 sentiment / tags / summary
    payload:   任务输入,交给平台路由去选择模型
    """
    if not BASE_URL or not API_KEY:
        raise RuntimeError("缺少 LANYUN_BASE_URL 或 LANYUN_API_KEY")

    headers = {
        "Authorization": f"Bearer {API_KEY}",
        "Content-Type": "application/json",
    }
    body = {
        "task": task_name,      # 关键:传任务名,不传模型名
        "input": payload,
    }
    resp = requests.post(
        f"{BASE_URL}/route",
        headers=headers,
        data=json.dumps(body),
        timeout=timeout,
    )
    resp.raise_for_status()
    return resp.json()

为什么这么写,有几个考虑:

第一,task 是唯一的路由依据。 我刻意不提供 model 参数。一旦留了口子,早晚有人会绕过路由直接指定模型,那张隐藏的耦合表就又回来了。

第二,超时是必须的。 批量评论分析动辄几百上千条,单条卡住会拖垮整批。我给了 30 秒默认值,实际批量场景会调小。

第三,payload 保持简单。 我只传原始文本和必要的指令,不在客户端拼接复杂的 prompt 模板------因为路由可能落到不同模型,而不同模型对 prompt 格式的偏好不一样,把模板放在路由后面更合理。

【踩坑提醒】/route 这个路径和 task / input 这两个字段名是我为了说明抽象层写的示例。蓝耘实际的接口路径和参数命名我没有验证过,接入时一定要对照官方文档改。我在这里强调的是参数语义------传任务不传模型------而不是具体的字段拼写。

三个任务,三套 prompt 意图

调用层统一了,接下来是任务本身。我把每个任务的指令写成一个独立的构造函数,让"意图"和"数据"分开。

python 复制代码
# tasks.py

def build_sentiment(text: str) -> dict:
    return {
        "instruction": "判断下面这条商品评论的情感倾向,只回答 positive / negative / neutral 三个词之一。",
        "text": text,
    }

def build_tags(text: str) -> dict:
    return {
        "instruction": (
            "从下面这条评论中抽取商品属性标签,"
            "以 JSON 数组返回,例如 [\"物流\", \"做工\"],不要输出其他内容。"
        ),
        "text": text,
    }

def build_summary(text: str) -> dict:
    return {
        "instruction": "用不超过 20 个字概括这条差评的核心问题,直接输出概括,不要解释。",
        "text": text,
    }

这里有个我一开始没想明白的点:指令里强调"只回答某个词""只输出 JSON"这类约束,实际效果取决于落到哪个模型。有的模型很听话,有的会加一句"好的,这条评论的情感是 positive"。所以我后面加了结果解析层来兜底,而不是指望 prompt 一定能约束住。

结果解析:不要假设模型输出是干净的

这是整个工具里最容易出问题的地方,也是我认为最值得单独讲的部分。

情感判断任务,我期望拿到的是 positive。但实际返回可能是:

  • positive
  • Positive
  • positive。
  • 这条评论的情感倾向是 positive。

标签抽取更麻烦,期望是 ["物流", "做工"],实际可能是:

  • 纯 JSON 数组
  • 被 ```json 代码块包起来的 JSON
  • 前面带一句"抽取结果如下:"再跟 JSON

所以我写了一个解析层,把"拿到原始文本"和"解析成结构化数据"分开:

python 复制代码
# parser.py
import re
import json

VALID_SENTIMENTS = {"positive", "negative", "neutral"}

def parse_sentiment(raw: str) -> str:
    text = raw.strip().lower()
    for label in VALID_SENTIMENTS:
        if label in text:
            return label
    return "unknown"   # 解析不出来就显式标记,不要瞎猜

def parse_tags(raw: str) -> list[str]:
    text = raw.strip()
    # 去掉可能的 markdown 代码块包裹
    text = re.sub(r"^```(?:json)?|```$", "", text, flags=re.MULTILINE).strip()
    # 尝试直接解析
    try:
        data = json.loads(text)
        if isinstance(data, list):
            return [str(x) for x in data]
    except json.JSONDecodeError:
        pass
    # 退一步:从文本里抓第一个 JSON 数组
    match = re.search(r"\[.*?\]", text, re.DOTALL)
    if match:
        try:
            data = json.loads(match.group())
            if isinstance(data, list):
                return [str(x) for x in data]
        except json.JSONDecodeError:
            pass
    return []   # 抽不到就返回空列表,交给上层决定怎么处理

def parse_summary(raw: str) -> str:
    # 摘要任务最简单:去掉首尾空白和可能的引号
    return raw.strip().strip("""\"'")

为什么要这么啰嗦?因为路由把模型选择权交出去了,就意味着输出格式的不确定性也一起交出去了。如果我还在客户端假设输出一定是干净的 JSON,那路由反而变成了风险来源。

parse_sentiment 里我特意用"包含"而不是"相等"来判断。因为 positive 是 positive 的子串,但 negative 里也含 negative 这个词,不会误判。不过这里有个细节:如果模型输出 not positive,我的逻辑会判成 positive,这是错的。这种情况我目前靠 prompt 约束 + 人工抽查发现,没有做更复杂的否定检测。这一点我没有做完整的验证,只是知道存在这个边界情况。

【关键结论】解析层要假设"输出可能不干净",并且解析失败时返回明确的标记值(unknown / 空列表),而不是抛异常或返回默认的正常值。前者会让脏数据静默通过,后者会让整批任务中断。

批量处理:并发、限流和失败重试

单条调通之后,真正的场景是批量。几百条评论,串行跑太慢,全并发又容易触发限流。

我用 concurrent.futures 做一个受控的并发池:

python 复制代码
# batch.py
from concurrent.futures import ThreadPoolExecutor, as_completed
from router_client import call_task
from tasks import build_sentiment, build_tags, build_summary
from parser import parse_sentiment, parse_tags, parse_summary

MAX_WORKERS = 5   # 保守值,避免触发平台限流

def analyze_one(text: str) -> dict:
    result = {"text": text}

    try:
        raw = call_task("sentiment", build_sentiment(text))
        result["sentiment"] = parse_sentiment(raw.get("output", ""))
    except Exception as e:
        result["sentiment"] = "error"
        result["sentiment_error"] = str(e)

    try:
        raw = call_task("tags", build_tags(text))
        result["tags"] = parse_tags(raw.get("output", ""))
    except Exception as e:
        result["tags"] = []
        result["tags_error"] = str(e)

    return result

def analyze_batch(texts: list[str]) -> list[dict]:
    results = []
    with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool:
        futures = {pool.submit(analyze_one, t): t for t in texts}
        for fut in as_completed(futures):
            results.append(fut.result())
    return results

几个设计取舍:

并发数我压得很低(5)。 因为路由背后到底落到哪个模型、那个模型的限流阈值是多少,我在客户端是不知道的。宁可慢一点,也不要因为 429 让整批失败。

每个任务单独 try。 情感判断失败了,不应该连累标签抽取。所以我把它们分开捕获,失败的那个字段标记出来,其他字段照常写入。

没有做自动重试。 这是我故意的------重试策略和限流策略耦合,而限流是平台侧的行为。如果盲目重试,可能在平台已经限流的情况下火上浇油。目前我的做法是记录失败项,人工决定是否重跑。更完善的重试机制我没有实现,也没有验证过退避策略在这个场景下的效果。

差评摘要只在需要时触发,避免对每条评论都调用:

python 复制代码
def summarize_negatives(results: list[dict]) -> list[dict]:
    for r in results:
        if r.get("sentiment") == "negative":
            try:
                raw = call_task("summary", build_summary(r["text"]))
                r["summary"] = parse_summary(raw.get("output", ""))
            except Exception as e:
                r["summary"] = ""
                r["summary_error"] = str(e)
    return results

路由带来的一个实际好处和两个代价

用下来,好处很直接:代码里再也没有模型名 。搜索整个项目,找不到任何 model-a、model-b 这样的字符串。模型换代、调整策略,我这边不用动代码。

但代价也是真实的,得说清楚。

代价一:可观测性变差。 以前我知道每条请求用了哪个模型,出了问题能直接定位。现在不知道了。如果平台返回里带了实际使用的模型标识,一定要记下来;如果没带,那排查问题会麻烦很多。我目前的做法是在日志里把整个响应存下来,但蓝耘的响应里是否包含实际模型信息,我没有验证过。

代价二:调试变难。 以前可以固定一个模型反复调 prompt,现在每次可能落到不同模型,prompt 效果不稳定。我的应对是把 prompt 写得尽量"无脑"------约束明确、格式简单,减少对特定模型风格的依赖。

下面这张表是我对两种方案的实际感受对比:

方案 优点 缺点 适用场景
代码里硬编码模型 可观测性好,调试可控,行为确定 模型变更需改代码发版,多任务难兼顾性价比 任务单一、模型稳定、对确定性要求高的场景
交给平台智能路由 代码与模型解耦,多任务自动择优 可观测性下降,调试不确定,依赖平台能力 任务多样、模型更新频繁、想省运维成本

我的判断是:任务类型多、模型更新频繁的场景适合路由;单一任务、追求极致可控的场景,硬编码反而更省心。 这不是"路由一定更好",而是取舍。

一个我没绕过去的边界

有个问题我一直没解决好:结构化输出任务(比如标签抽取)对模型能力有硬要求。

如果路由为了省钱,把一个要求输出 JSON 的任务落到了不擅长结构化输出的小模型上,返回的就是一堆自然语言,我的解析层只能返回空列表。这时候我无法区分"这条评论确实没有可抽取的标签"和"模型没按要求输出"。

我目前的处理是:解析失败返回空列表,同时在结果里加一个标记。但更根本的解法应该是让路由知道"这个任务需要结构化输出能力"。蓝耘的智能路由是否支持按输出格式或能力要求来筛选模型,这一点我没有验证,无法给出确定答案。 如果你的场景里结构化任务占比高,接入前建议先确认这一点。

最后

这套工具现在的形态是:三个任务意图(sentiment / tags / summary),一个统一的 call_task 入口,一个防御性的解析层,一个受控并发的批处理。代码里没有任何模型名。

它不算完美。可观测性、结构化输出的能力匹配、失败重试,都还有没做透的地方。但至少解决了我最初的那个痛点------不用再维护一张自己都记不清来由的模型映射表了。

如果你也在做类似的评论分析或者文本批处理,我建议先把"任务意图"和"模型实现"这两层拆开。不管最后用不用平台路由,这个拆分本身就能让代码干净不少。至于路由规则怎么配、能力怎么筛,那部分得结合你实际用的平台文档来定,我文中标了"没验证"的地方,请务必自己确认一遍再上生产。

相关推荐
后端LV1 小时前
Caffeine 源码详解:为什么它是最快的 Java 本地缓存——一次压测引发的源码考古
java·后端
不可能片场1 小时前
Electron 发布标题的冒号 击穿命令行解析
前端·electron
dadaobusi1 小时前
Trace-driven 建模(基于轨迹/踪迹的建模)
java·后端·spring
杨云龙UP1 小时前
TDengine Community 超级表建表实战:统一21个TAG与DOUBLE/字符串数据模板
运维·服务器·数据库·时序数据库·tdengine·涛思数据·stable建表
ynchyong1 小时前
python list 地常用操作
开发语言·python
reeswell1 小时前
我开源了 inspect-devtools —— 让 AI 终于能"看见"你屏幕上的组件
前端·人工智能
摇滚侠1 小时前
《Spring Boot 3:高级与架构设计》第 3 章 Bean 的全生命周期原理 Bean 的全生命周期概览 阅读笔记 9
spring boot·笔记·后端
墨家句子1 小时前
AnythingLLM 搭本地知识库:文档问答不准怎么调
linux·后端
Yuhano1 小时前
W3. 实现Agent工具调用引擎
前端·aigc·ai编程