AB
AiBoss
Tutorials

用 gradio.Server 构建可扩展的 PII 脱敏 Web 应用:三种前端形态的完整实现

Tutorials

用 gradio.Server 构建可扩展的 PII 脱敏 Web 应用:三种前端形态的完整实现

介绍如何以 gradio.Server 为统一后端,把 PII 检测模型接入自定义 HTML/JS 前端,构建文档隐私浏览器、图片匿名化工具与可分享的脱敏粘贴板三类应用,并说明排队端点与普通 FastAPI 路由的分工原则。

处理含个人身份信息(PII)的文本时,常见的需求不是「跑一次模型」,而是把检测结果嵌进一个真正好用的界面里:文档要能像正常文档那样阅读、图片要能在浏览器里随手涂改、粘贴板要能生成一条可以公开分享的链接。这类应用的后端逻辑高度相似——都是「取文本、跑一次检测、把结果映射回原始载体」——但前端形态差别极大。gradio.Server 提供的正是这种场景下的分工方式:把触碰模型的计算放进带队列的端点,把页面、文件读取、字典查询这类轻量操作交给普通 FastAPI 路由。本文按这个思路,把三类应用的实现路径拆开讲清楚。

准备工作

模型与运行环境

本文涉及的检测模型是一个约 1.5B 参数、其中约 50M 为激活参数的 PII 检测模型,采用 Apache 2.0 许可。它在单次前向传播中处理最长 128,000 token 的上下文,输出八类标签:

  • private_person:人名
  • private_address:地址
  • private_email:邮箱
  • private_phone:电话
  • private_url:网址
  • private_date:日期
  • account_number:账号类编号
  • secret:密钥、令牌等敏感串

模型在 PII-Masking-300k 基准上取得了当前最优水平。具体的评测数字、方法说明以及许可条款,请以模型主页与官方发布说明的当前信息为准,本文不复述可能变动的细节。

依赖与前置条件

三类应用共用的依赖大致如下,按实际项目取舍:

  • gradio:提供 gradio.Server、队列与 gradio_client
  • fastapigradio.Server 本身就是一个 FastAPI 应用,路由装饰器直接可用
  • PyMuPDFpython-docx:分别用于 PDF 与 DOCX 的文本抽取
  • Pillow:图片读取与转 base64
  • pytesseract 与 Tesseract 本体:图片 OCR 与逐词包围盒

如果部署在 ZeroGPU 之类的按需 GPU 环境上,需要确认 @spaces.GPU 装饰器与队列端点的组合方式;这一点在下面的步骤里会说明。此外,模型权重需要从模型仓库拉取,首次加载会有明显延迟,建议在服务启动阶段完成预热,而不是等第一个请求进来才加载。

需要先想清楚的一件事

在动手之前,先确定哪些操作必须经过模型。判断标准很简单:只要这一步需要模型推理,就走 @server.api;只要这一步是返回 HTML、读一个字典、查一个文件,就走普通的 @server.get@server.post。这条规则贯穿下面三个应用,也是它们虽然界面完全不同、代码结构却保持一致的原因。

操作步骤

第一步:建立服务骨架

所有应用都从一个 gr.Server() 实例开始。它同时是 FastAPI 应用,因此可以混用两类装饰器:

import gradio as gr
from fastapi.responses import HTMLResponse
from gradio.data_classes import FileData

server = gr.Server()

@server.get("/", response_class=HTMLResponse)
async def homepage():
    return FRONTEND_HTML

@server.api(name="analyze_document")
def analyze_document(file: FileData) -> dict:
    text = extract_text(file["path"])
    source_text, spans = run_privacy_filter(text)
    return {
        "text": source_text,
        "spans": spans,
        "stats": compute_stats(source_text, spans),
    }

这里的关键是装饰器写成了 @server.api(name="analyze_document"),而不是普通的 @server.post。差别在于前者会把处理函数接入 Gradio 的队列:并发上传会被串行化,@spaces.GPU 在 ZeroGPU 上能正确组合,进度事件也能正常上报。更重要的是,同一个端点既能被浏览器调用,也能被 Python 的 gradio_client 调用,不需要为两套客户端各写一份逻辑。

第二步:让浏览器调用队列端点

前端通过 Gradio 的 JS 客户端连接当前页面所在的源,然后按端点名调用:

<script type="module">
import { Client, handle_file } from "...";

const client = await Client.connect(window.location.origin);

async function uploadFile(file) {
  const result = await client.predict("/analyze_document", {
    file: handle_file(file),
  });
  renderResults(result.data[0]);
}
</script>

返回的 result.data[0] 就是后端那个字典,包含原文、span 列表与统计信息。span 的每一项形如 {start, end, label},偏移量直接对应原文,因此前端可以把它当作纯文本渲染后再叠加高亮,而不需要重新切分文本。

第三步:文档隐私浏览器

这个应用解决的问题是:拿到一份 PII 密集的文档(合同、简历、导出的聊天记录),希望像读普通文档一样读它,同时每个被检测到的片段按类别高亮,侧边栏可以按类别筛选,顶部有一块汇总面板。

模型侧的优势在这里体现得很直接:整份文件在一次 128k 上下文的前向传播中处理完,不需要分块,也不需要把分块结果拼接回去,因此 span 的偏移量与渲染后的文本天然对齐。BIOES 解码则保证在长距离、边界模糊的片段上仍能切出干净的起止位置。

前端方面,用 gr.HighlightedText 加侧边栏在 Blocks 里也能拼出来,但想要的那种阅读体验——衬线正文、按类别切换 CSS class 而不重新跑模型、汇总面板不触发整页重渲染——手写 HTML 反而更省事。于是页面作为一个静态 HTML 文件由 GET / 提供,模型只暴露在 analyze_document 这一个队列端点后面。筛选逻辑完全在前端完成:切换类别只是增删 class,不会产生任何网络请求。

第四步:图片匿名化

这个应用要解决的是:把一张截图(聊天记录、收据、后台面板)分享出去,但人名、邮箱、账号上要盖黑条;黑条要能开关、能拖动、能手动补画模型漏掉的部分,最后导出成图。

处理链路分三段:

  1. Tesseract 做 OCR,返回逐词的包围盒。
  2. 后端用字符偏移到包围盒的映射重建全文,然后对整段文本跑一次检测。
  3. 把检测到的字符区间对照词映射,按行合并成像素矩形。

端点返回的内容包括原图的 base64、宽高,以及矩形列表:

@server.api(name="anonymize_screenshot")
def anonymize_screenshot(image: FileData) -> dict:
    img = Image.open(image["path"]).convert("RGB")
    full_text, char_to_box = ocr_image(img)
    spans = run_privacy_filter(full_text)
    boxes = spans_to_pixel_boxes(spans, char_to_box)
    return {
        "image_data_url": pil_to_base64(img),
        "width": img.width,
        "height": img.height,
        "boxes": boxes,
    }

每个矩形形如 {x, y, w, h, label, text},带上类别与命中的文本,前端才能按类别批量开关。调用方式与前面一致:client.predict("/anonymize_screenshot", { image: handle_file(file) })

这里同样可以选择用 gr.ImageEditor,它本身支持分层标注,作为图片脱敏的起点是合理的。但需求里的几项——每个黑条带类别元数据、按类别一次性开关、在浏览器里以原始分辨率导出 PNG 而不经过服务器——放在自定义 <canvas> 上更顺。于是后端只负责返回像素矩形,其余全部交给 canvas:开关、拖动、手绘新条、导出,编辑过程不会回传服务器。

第五步:可分享的脱敏粘贴板

这个应用相当于一个「分享前先脱敏」的粘贴板。贴进一段日志、一封邮件、一张工单,返回两个链接:公开链接提供脱敏后的版本,敏感片段被替换成 <PRIVATE_PERSON><PRIVATE_EMAIL><ACCOUNT_NUMBER> 这类占位符;私有链接带一个只有你知道的令牌,打开后显示原文并高亮所有命中片段。

脱敏这一步本身很简单,就是把每个检测到的区间替换成对应的占位符。多语言文本(模型示例中覆盖了西班牙语、法语、中文、印地语等)走的是同一个调用,不需要额外处理。

真正需要设计的是路由。同一个粘贴 ID 要有两个不同的 GET 路径,一个公开、一个带令牌,而且 URL 的形状很重要,因为私有链接是用户要长期保存的东西。这正是 gr.Server 合适的场景——它底层就是 FastAPI 应用,所以 @server.api 和普通的 @server.get 可以在同一个进程里并存:

@server.api(name="create_paste")
def create_paste(text: str, ttl: str = "never") -> dict:
    source_text, spans = run_privacy_filter(text)
    redacted = redact(source_text, spans)
    pid = secrets.token_urlsafe(6)
    reveal_token = secrets.token_urlsafe(22)
    PASTES[pid] = Paste(
        pid, reveal_token, source_text, redacted, spans,
        expires_at=_ttl(ttl),
    )
    return {
        "view_path": f"/view/{pid}",
        "reveal_path": f"/view/{pid}?token={reveal_token}",
    }

@server.get("/view/{pid}", response_class=HTMLResponse)
async def view_paste(pid: str, token: str | None = None):
    p = _store_get(pid)
    if p is None:
        return HTMLResponse(_not_found(), status_code=404)
    revealed = bool(token) and secrets.compare_digest(token, p.reveal_token)
    return HTMLResponse(_render_view(p, revealed))

创建走队列端点,因为要跑模型;查看走普通 GET,因为没有模型、不需要排队,而且 /view/{pid}?token=... 这种自定义 URL 形状本来也不是队列端点能提供的。令牌比较用 secrets.compare_digest,避免时序侧信道。过期清理用一个守护线程每 30 秒扫一次即可。整个服务连同存储大约两百行应用代码,因为所有东西都在一个进程里。

一个完整示例

下面把三类应用的分工整理成一张表,可以直接当作新项目的骨架来套:

应用队列端点(@server.api普通 FastAPI 路由
文档隐私浏览器analyze_document:抽取文本、检测、统计GET / 提供自定义阅读视图
图片匿名化anonymize_screenshot:OCR、检测、区间转像素矩形GET /GET /examples/* 提供画布界面与预置示例
脱敏粘贴板create_paste:检测、脱敏、生成 IDGET / 组合页、GET /view/{pid}?token=... 公开与令牌视图、GET /api/paste/{pid} JSON 查询

把这张表落到代码上,一个最小可跑的骨架是这样的:

import gradio as gr
from fastapi.responses import HTMLResponse
from gradio.data_classes import FileData

server = gr.Server()

@server.get("/", response_class=HTMLResponse)
async def homepage():
    return FRONTEND_HTML

@server.api(name="analyze_document")
def analyze_document(file: FileData) -> dict:
    text = extract_text(file["path"])
    source_text, spans = run_privacy_filter(text)
    return {
        "text": source_text,
        "spans": spans,
        "stats": compute_stats(source_text, spans),
    }

server.launch()

前端只需一个 HTML 文件加一段模块脚本,通过 Client.connect(window.location.origin) 连上同一个源,再调用 /analyze_document。把 extract_text 换成 OCR 加字符映射,就是图片匿名化;把返回值换成脱敏文本加两个路径,就是粘贴板。三种形态共用同一套后端约定,这也是它们在界面差异巨大的情况下仍然保持一致的原因。

注意事项

  • 队列端点与普通路由不要混用。 需要模型推理的一律走 @server.api,否则会失去请求串行化、ZeroGPU 上的 @spaces.GPU 正确组合以及进度事件;反过来,返回 HTML 或读字典这类操作没必要进队列,白白占用并发额度。
  • 上下文长度是硬上限。 单次前向传播覆盖 128,000 token,超出这个范围的长文档需要自行设计切分策略,而切分之后 span 偏移量就不再与原文天然对齐,需要额外做偏移补偿。
  • OCR 质量决定图片脱敏的上限。 逐词包围盒来自 OCR,识别错误会直接导致漏检或错位;手绘补条正是为这种情况准备的兜底手段。
  • 令牌比较要用常量时间函数。 私有链接的令牌校验如果直接用 ==,会引入时序差异,使用 secrets.compare_digest 更稳妥。
  • 过期清理需要独立线程。 粘贴板的数据如果只靠请求触发清理,长期没有访问的记录会一直留在内存里;用守护线程定期扫描是简单可行的做法。
  • 模型可能漏检。 检测结果不应被当作合规保证,尤其是面向特定领域(如临床记录)的文本,通用类别体系未必覆盖该领域真正需要区分的实体类型。上线前应针对自己的数据做覆盖度验证。
  • 许可与配额以官方为准。 模型的许可条款、可用区域、推理配额以及部署环境的计费方式都可能调整,落地前请查阅模型主页与部署平台的当前说明。