在 Amazon Bedrock 上构建可上线的 LLM 质量保障流水线

2026-09-26 22 预计阅读时间: 1 分钟
来源: aws.amazon.com AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:13 分钟

LLM 应用从演示走向生产,难点往往不再是“能否生成答案”,而是能否在模型超时、账号隔离、流式输出和数字密集型任务中持续给出可信结果。NarrateAI 展示了一套运行在 Amazon Bedrock 上的质量保障方案,结合自适应编排、跨账号多模型故障转移、实时流式评估、复合评分和数据准确性验证,在其场景中实现了约 99% 的数值准确率,同时保留实时响应能力。

这里的关键并不是某一个提示词,而是把质量控制做成一条可观测、可降级、可阻断的工程流水线。

把一次模型调用拆成可编排的流水线

生产请求不应全部走同一条路径。一个开放式文案请求和一个包含金额、比例、日期的财务问答,风险完全不同。自适应编排可以先识别任务类型,再决定需要哪些步骤:

  1. 低风险生成:直接调用主模型,执行基础安全检查。
  2. 数据密集型问答:先检索或读取权威数据,再要求模型基于证据回答。
  3. 高风险计算:生成答案后,由确定性代码重新计算并核对数字。
  4. 复杂推理:增加独立评估模型,检查完整性、证据覆盖和指令遵循情况。

可以用一份简单配置描述这些策略,而不是把判断逻辑散落在业务代码里:

pipelines:
  default:
    evaluators:
      - safety
      - instruction_following
    threshold: 0.75

  numeric:
    evaluators:
      - safety
      - required_evidence
      - numeric_accuracy
    threshold: 0.92
    stream_policy: sentence_buffered

routing:
  numeric_keywords:
    - revenue
    - margin
    - percentage
    - total
    - 增长率
    - 合计
    - 利润率

配置中的阈值需要通过自己的标注集校准。不能因为某个系统报告了约 99% 的数值准确率,就把这一结果直接视为其他数据集、模型和提示词上的保证。

跨账号、多模型故障转移不能只做“重试”

Amazon Bedrock 的生产部署经常涉及多个 AWS 账号、区域或模型。跨账号隔离有助于控制权限和故障域,但故障转移需要处理几个容易忽视的问题:

  • 使用 AWS STS 承担目标账号角色,并给予最小化的 Bedrock 调用权限。
  • 为不同模型准备兼容的请求模板,而不是假设所有模型参数完全一致。
  • 只对限流、暂时不可用和服务端错误执行故障转移;权限错误和输入错误应直接暴露。
  • 如果已经向用户发送了部分 token,不要悄悄切换模型并从头生成,否则可能出现重复或语义跳变。
  • 记录实际使用的账号、区域、模型、重试次数和评估结果,便于追踪质量回退。

下面的 Python 示例展示了一种可以改造的最小实现:它通过环境变量配置多个 Bedrock 目标,逐个承担角色并调用 ConverseStream;只有在尚未输出任何文本时,才允许切换到备用模型。示例还在流式阶段执行基础监控,并在结束后计算复合质量分数。

运行前需要安装 boto3,并确保当前身份拥有 sts:AssumeRole 权限;目标角色还需要相应的 Bedrock 模型调用权限。模型 ID、区域和角色 ARN 必须替换成自己的值。

python -m pip install 'boto3>=1.34.131'

export BEDROCK_TARGETS='[
  {"region":"us-east-1","role_arn":null,"model_id":"YOUR_PRIMARY_MODEL_ID"},
  {"region":"us-west-2","role_arn":"arn:aws:iam::123456789012:role/BedrockInvokeRole","model_id":"YOUR_FALLBACK_MODEL_ID"}
]'
python bedrock_qa.py

将下面内容保存为 bedrock_qa.py:

import json
import os
import re
import sys
from decimal import Decimal, InvalidOperation

import boto3
from botocore.exceptions import ClientError

TARGETS = json.loads(os.environ["BEDROCK_TARGETS"])
RETRYABLE = {
    "ThrottlingException",
    "ServiceUnavailableException",
    "InternalServerException",
    "ModelTimeoutException",
}


def runtime_client(target):
    if not target.get("role_arn"):
        return boto3.client("bedrock-runtime", region_name=target["region"])

    sts = boto3.client("sts")
    credentials = sts.assume_role(
        RoleArn=target["role_arn"],
        RoleSessionName="bedrock-quality-gateway",
    )["Credentials"]

    return boto3.client(
        "bedrock-runtime",
        region_name=target["region"],
        aws_access_key_id=credentials["AccessKeyId"],
        aws_secret_access_key=credentials["SecretAccessKey"],
        aws_session_token=credentials["SessionToken"],
    )


def normalize_number(value):
    try:
        return Decimal(value.replace(",", "").rstrip("%"))
    except InvalidOperation:
        return None


def extract_numbers(text):
    matches = re.findall(r"-?\d[\d,]*(?:\.\d+)?%?", text)
    return {n for raw in matches if (n := normalize_number(raw)) is not None}


def evaluate(text, expected_numbers, required_terms):
    actual = extract_numbers(text)
    expected = {Decimal(str(number)) for number in expected_numbers}

    numeric_score = (
        len(expected & actual) / len(expected) if expected else 1.0
    )
    term_score = (
        sum(term.lower() in text.lower() for term in required_terms)
        / len(required_terms)
        if required_terms
        else 1.0
    )
    safety_score = 0.0 if "BEGIN PRIVATE KEY" in text else 1.0

    composite = (
        0.60 * numeric_score
        + 0.25 * term_score
        + 0.15 * safety_score
    )
    return {
        "numeric_score": round(numeric_score, 3),
        "required_term_score": round(term_score, 3),
        "safety_score": round(safety_score, 3),
        "composite_score": round(composite, 3),
        "numbers_seen": sorted(str(number) for number in actual),
    }


def stream_with_failover(prompt):
    for index, target in enumerate(TARGETS):
        emitted = False
        pieces = []
        try:
            response = runtime_client(target).converse_stream(
                modelId=target["model_id"],
                messages=[{
                    "role": "user",
                    "content": [{"text": prompt}],
                }],
                inferenceConfig={
                    "maxTokens": 500,
                    "temperature": 0.0,
                },
            )

            for event in response["stream"]:
                delta = event.get("contentBlockDelta", {}).get("delta", {})
                text = delta.get("text")
                if not text:
                    continue

                emitted = True
                pieces.append(text)
                sys.stdout.write(text)
                sys.stdout.flush()

                # 轻量级实时检查;复杂事实校验应放在句子缓冲区之后。
                if sum(map(len, pieces)) > 20_000:
                    raise RuntimeError("Response exceeded the configured limit")

            print()
            return "".join(pieces), target

        except ClientError as exc:
            code = exc.response.get("Error", {}).get("Code", "Unknown")
            can_fail_over = (
                not emitted
                and code in RETRYABLE
                and index < len(TARGETS) - 1
            )
            if can_fail_over:
                print(
                    f"Primary target failed with {code}; trying fallback",
                    file=sys.stderr,
                )
                continue
            raise

    raise RuntimeError("No Bedrock target produced a response")


if __name__ == "__main__":
    expected_revenue = 125.4
    expected_margin = 18.2
    prompt = f"""
请仅根据以下已验证数据回答,并保留单位:
- 本季度收入:{expected_revenue} 百万元
- 利润率:{expected_margin}%

用一句话总结本季度表现。不要推测未提供的数据。
""".strip()

    answer, selected_target = stream_with_failover(prompt)
    report = evaluate(
        answer,
        expected_numbers=[expected_revenue, expected_margin],
        required_terms=["收入", "利润率"],
    )

    print(json.dumps({
        "selected_target": selected_target,
        "evaluation": report,
        "accepted": report["composite_score"] >= 0.92,
    }, ensure_ascii=False, indent=2))

这个示例故意保持了边界清晰:它能验证预期数字是否出现,却不能理解“18.2%”与“0.182”是否等价,也无法判断年份、币种和数字之间的关系。生产实现应把数值抽取升级为结构化字段校验,例如同时验证 metric、period、value、unit 和 source_record_id。

流式评估的核心是决定何时允许内容离开系统

“边生成边评估”并不意味着检查完成前就把所有 token 原样发给用户。不同风险等级需要不同的输出策略:

  • 直接透传:延迟最低,适用于低风险内容;发现问题后通常已经无法撤回。
  • 固定 token 缓冲:积累几十个 token 后检查,再批量发送,能拦截部分敏感内容。
  • 按句缓冲:等待句号或段落结束,对完整语义单元执行检查,适合事实和数字验证。
  • 完整答案门控:全部生成并通过评估后才返回,质量控制最强,但失去真正的流式体验。

数字校验尤其适合按句缓冲,因为数字 token 可能被拆分,单独检查每个增量容易产生误报。若检测到错误,可以重写当前句、回退到备用模型,或向客户端发送结构化错误事件,而不是继续输出未经验证的内容。

对于 Server-Sent Events,可以约定业务事件类型:

event: delta
data: {"text":"本季度收入为 125.4 百万元"}

event: quality
data: {"numeric_score":1.0,"composite_score":0.95}

event: done
data: {"accepted":true,"model":"fallback-or-primary-id"}

客户端不应只拼接 delta。它还需要处理 quality、blocked、retrying 和 done 等状态,才能把后端质量控制真实反映给用户。

复合评分比单一“正确/错误”更适合生产决策

单个评估器容易掩盖问题。例如答案包含所有正确数字,但把收入说成利润;或者事实正确,却泄露了不应返回的内部内容。复合评估可以把多个维度组合起来:

  • 数值准确性与单位一致性;
  • 是否引用了要求使用的证据;
  • 回答是否覆盖用户问题;
  • 安全、隐私和合规检查;
  • 格式、长度和指令遵循情况。

不过,加权平均也有陷阱。安全检查不应被其他高分抵消,因此更合理的策略是同时使用硬门槛和软评分:

接受条件 = 安全检查通过
        AND 数值准确率 >= 0.98
        AND 复合分数 >= 0.92

权重和阈值必须基于真实业务数据调优。评估集至少应覆盖正常请求、边界值、缺失数据、冲突来源、模型拒答、限流和流式中断等情况。

上线前的工程检查清单

将这套思路落地时,可以按以下顺序推进:

  • 为请求标注风险等级,不让所有任务共享一条生成路径。
  • 使用结构化事实作为数字校验依据,避免再调用一个 LLM 来“猜”数字是否正确。
  • 为每个备用模型执行兼容性测试,并记录实际发生的模型切换。
  • 只在安全的边界进行流式故障转移,避免重复输出和语义断裂。
  • 对高风险内容采用句子缓冲或完整门控,而不是无条件透传 token。
  • 将原始回答、证据版本、模型 ID、评估器版本和各项分数写入审计日志。
  • 用自己的黄金数据集持续回归测试,不把约 99% 当成脱离场景的通用承诺。

真正可上线的 LLM 质量保障,不是给模型调用包一层重试,而是把路由、冗余、验证、流式协议和审计连接成一个闭环。这样即使底层模型或账号发生变化,应用仍然拥有明确、可测量的质量边界。


相关推荐