跳到主要内容

🪄 Filter 函数:修改输入和输出

⚠️ 关键安全警告

Filter 函数会在你的服务器上执行任意 Python 代码。 函数创建仅限管理员。请只从可信来源安装,并在导入前审查代码。恶意函数可能访问文件系统、窃取数据,甚至危及整套系统。完整说明请参阅 插件安全警告

欢迎阅读 Open WebUI 中 Filter 函数的完整指南!Filter 是一个灵活而强大的 插件系统,可以在数据发送给大语言模型(LLM)之前(输入)或从 LLM 返回之后(输出)修改数据。无论你是在转换输入以获得更好的上下文,还是在清理输出以提升可读性,Filter 函数都能帮你完成。

本指南会拆解Filter 是什么、它们如何工作、它们的结构,以及构建强大又易用的自定义 Filter 需要知道的一切。我们会一步步讲清楚,并用比喻、示例和技巧让内容尽量一目了然。🌟


🌊 Open WebUI 中的 Filter 是什么?

你可以把 Open WebUI 想象成一条在管道中流动的水流

  • 用户输入LLM 输出就是水。
  • Filters 则像是水处理工序,在水到达最终目的地之前,先对它进行清理、修改和适配。

Filter 位于流程中间,就像检查点一样,你可以在这里决定哪些内容需要被调整。

下面快速总结一下 Filter 的作用:

  1. 修改用户输入(Inlet 函数):在输入到达 AI 模型之前对其进行调整。你可以在这里增强清晰度、补充上下文、清理文本,或者按照特定要求重新格式化消息。
  2. 拦截模型输出(Stream 函数):在模型生成回复时就捕获并调整 AI 的响应。这对实时修改很有用,比如过滤敏感信息或重新格式化输出以提升可读性。
  3. 修改模型输出(Outlet 函数):在 AI 响应处理完成后、展示给用户之前进行调整。这有助于改进、记录或适配数据,让用户体验更干净。

核心概念: Filter 不是独立模型,而是用于增强或转换模型输入与输出之间数据的工具。

Filter 就像 AI 工作流里的翻译器或编辑器:你可以在不打断流程的情况下拦截并修改对话内容。


🗺️ Filter 函数的结构:骨架

先从最简单的 Filter 函数表示法开始。别担心,一开始有些部分看起来会有点技术化——我们会一步一步拆开讲。

🦴 Filter 的基本骨架

from pydantic import BaseModel
from typing import Optional

class Filter:
    # Valves: Configuration options for the filter
    class Valves(BaseModel):
        pass

    def __init__(self):
        # Initialize valves (optional configuration for the Filter)
        self.valves = self.Valves()

    async def inlet(self, body: dict) -> dict:
        # This is where you manipulate user inputs.
        print(f"inlet called: {body}")
        return body

    async def stream(self, event: dict) -> dict:
        # This is where you modify streamed chunks of model output.
        print(f"stream event: {event}")
        return event

    async def outlet(self, body: dict) -> dict:
        # This is where you manipulate model outputs.
        print(f"outlet called: {body}")
        return body

🧲 可切换 Filter:让用户掌控 Filter 是否启用(self.toggle

默认情况下,只要 Filter 处于激活状态且在作用范围内(全局,或附加到当前模型),它就会在每次请求时执行——用户对此没有控制权。这在很多场景下正是你想要的(PII 清洗、日志、强制的安全护栏)。但有时你希望反过来:让用户自己决定某个 Filter 是否在本次会话中运行。

self.toggle = True 设置上,就能让 Filter 变成可由用户控制。随后它会作为一个可点击的 chip 出现在聊天 UI 上,并在“集成”菜单中占有一席之地;只有当用户主动选择了它时,Filter 才会运行。

from pydantic import BaseModel, Field
from typing import Optional

class Filter:
    class Valves(BaseModel):
        pass

    def __init__(self):
        self.valves = self.Valves()
        self.toggle = True   # 将该 Filter 设为用户可控(详见下文说明)
        # 提示:建议使用托管的图标 URL,而不是 base64,避免给 API 载荷带来负担。
        # 关于为什么不推荐 base64 图标,参见 Action Function 文档中的说明。
        self.icon = "https://example.com/icons/lightbulb.svg"

    async def inlet(
        self, body: dict, __event_emitter__, __user__: Optional[dict] = None
    ) -> dict:
        # 该方法仅在用户当前选中了该 Filter 时才会运行。
        # 在这里你无需再根据 self.toggle 做分支判断——详见下方说明。
        await __event_emitter__(
            {
                "type": "status",
                "data": {"description": "Running!", "done": True, "hidden": False},
            }
        )
        return body

self.toggle = True 到底做了什么

它是一个可见性 / 门控标志,在请求分发时读取一次——并不是运行时由 UI 翻转的 Python 对象状态。具体来说:

  • 可见性: 只有当 self.toggle = True 并且 Filter 是全局 Filter 或已附加到当前选中的模型时,该 Filter 才会出现在聊天 UI(内联标签 + 集成菜单条目)中。没有 self.toggle,Filter 仍会运行(如果处于激活且在作用范围内),但没有 UI 入口——用户无法关闭它。
  • 门控: 在请求时,后端会检查用户当前的 filter_ids 选择。如果 Filter 在该列表中,则 inlet() / stream() / outlet() 会被调用;否则,Filter 根本不会被调用
  • self.toggle 永远不会被 UI 修改。inlet() 内部,它始终是你在 __init__ 中设置的值——对于每次实际运行的调用,它都是 True,因为如果用户禁用了该 Filter,inlet() 就不会执行。不要在运行时读取 self.toggle 来做逻辑判断;它不是实时的开关信号。
从 0.9.0 之前的 Filter 升级

一些旧版 Filter 使用了类似 if self.toggle: enable_feature() else: disable_feature() 这样的模式放在 inlet() 内,期望在每次请求时读取 UI 状态。该模式从未可靠,在 0.9.0+ 上实际上已失效。 当 UI 中禁用了 Filter,inlet() 根本不会被调用,因此不会有 "else" 分支。正确的迁移方式是完全停止对 self.toggle 的分支判断,直接无条件地执行逻辑——用户通过选择/取消选择标签来控制 inlet() 是否运行。如果你需要用户驱动的配置(数字阈值、目标语言等),请通过 UserValves 暴露;点击标签会自动打开用户 valves 模态框。

用户如何与可切换 Filter 交互

当一个可切换 Filter 在当前聊天的作用范围内时,会出现两个 UI 入口:

  • 聊天输入栏的内联标签。 显示 Filter 的 self.icon 和名称。点击它:
    • 如果 Filter 定义了 UserValves 类,则打开用户 valves 模态框(让用户调整每次聊天的设置);否则
    • 从当前会话的选中列表中移除该 Filter(标签从内联行中消失)。
  • 集成菜单(⚙️ 图标)。 列出作用范围内所有可切换的 Filter,每个都有一个正式的开/关开关。用户可在此重新启用已从标签行移除的 Filter,或关闭默认选中的 Filter。

标签存在 = 该 Filter 在下次请求时启用。标签不存在(但 Filter 在集成菜单中) = 用户已关闭它。

选中状态存储在哪里

  • 浏览器 sessionStorage 草稿状态形式存储,按聊天区分。
  • 在同一浏览器会话中页面刷新后仍然存在,但不会持久化到服务器上的聊天记录
  • 初始状态来自模型的 defaultFilterIds(管理面板 → 模型设置 → 默认 Filters)——管理员决定每个模型的哪些可切换 Filter 默认开启关闭
  • 切换到不同模型时会重置。

Toggle Filter

self.icon 仍然按原来的方式工作:传一个 URL(强烈推荐)或 base64 data URI,它就会同时出现在内联标签和集成菜单的条目中。关于为何更推荐托管 URL 而不是 base64,请参阅 Action Function icon_url 警告


🛠️ 通过 file_handler 接管检索

默认情况下,当用户在聊天中挂载知识集合或上传文件时,Open WebUI 会在所有 inlet filter 全部返回之后才运行内置 RAG 流程:chat-completion 处理程序会基于用户的最后一条消息在向量库中查询相关 chunk,把它们包进 <source> 标签,再追加到最后一条用户消息(或者,根据 RAG_SYSTEM_CONTEXT,追加到系统消息)中,最后才调用 LLM。

这一点对 filter 编写者来说很关键:在 inlet() 时刻,body["metadata"]["files"]body["files"] 里只有文件/集合的引用(ID、名称、类型)。真正的 chunk 文本此刻还不存在——检索还没发生。因此,如果你想检查或改写这些 chunk 本体(PII / PHI 脱敏、重排序、自定义混合打分、翻译、chunk 级访问控制、匿名化),标准的 inlet 契约就不够了——你要的数据还没生成。

file_handler = True 正是为这种场景准备的“自助”开关。它被声明为 filter 文件顶部的模块级属性,含义是告诉 Open WebUI:“我自己来处理检索和 chunk 注入——请跳过内置 RAG 步骤。”一旦开启,后端会在你的 inlet() 返回后剥离 body["metadata"]["files"]body["files"],于是 chat-completion 处理程序找不到可检索的文件,就会带着你注入的内容直接调用 LLM。

from pydantic import BaseModel
from typing import Optional

# 模块级属性 —— 写在 Filter 类**外面**,紧挨着 import。
file_handler = True

class Filter:
    class Valves(BaseModel):
        pass

    def __init__(self):
        self.valves = self.Valves()

    async def inlet(
        self,
        body: dict,
        __request__=None,
        __user__: Optional[dict] = None,
        __model__: Optional[dict] = None,
    ) -> dict:
        # 此时 body["metadata"]["files"] 中仍是文件/集合的**引用**。
        # 该方法返回后,Open WebUI 会剥离它们,**不会**再跑自己的 RAG。
        # 因此:检索、改写、注入 chunk 都得由你在这里完成。
        return body
它是模块属性,不是 self.file_handler

Open WebUI 读取的是模块对象上的 file_handler(也就是 filter 所在的那个文件),不是 Filter 实例上的。在 __init__ 里写 self.file_handler = True 会被静默忽略。请把赋值放在文件顶部、紧挨着 import——和上面的示例一样。

适用场景

  • 按模型脱敏。 只有当请求目标是远端模型时才对 PII / PHI 做脱敏,让自托管模型看到原始 chunk。可以在 inlet 内根据 __model__["owned_by"](或其他信号)分支判断后再改写 chunk。
  • 自定义检索逻辑。 混合 BM25 + dense 打分、查询改写、多集合路由、用与 Open WebUI 默认不同的模型做重排序、基于改写后查询做结果缓存。
  • 注入前变换。 翻译、摘要、去重,或任何需要拿到实际 chunk 文本而不仅仅是引用的转换。
  • chunk 级访问控制。 根据源文档上附带的元数据,过滤掉当前用户不应看到的 chunk。

完整步骤

  1. 在 filter 模块顶部设置 file_handler = True

  2. inlet() 中,从 body["metadata"]["files"](以及临时挂载的 body["files"])读取文件引用。

  3. 自行检索 chunk。两种方式:

    • HTTP 方式:调用 POST /api/v1/retrieval/query/doc(单集合)或 POST /api/v1/retrieval/query/collection(多集合),把用户最后一条消息作为查询串,并传入请求里的 bearer token,使权限仍受用户范围限制。
    • 进程内方式from open_webui.retrieval.utils import get_sources_from_items,并使用与核心代码相同的参数直接调用。这样可省去一次网络往返,且返回结构更干净(一个 list,每个元素是包含 document chunk 数组与平行 metadata 数组的 dict)。
  4. 按需转换 chunk。如果转换是条件式的(例如“只对远端模型脱敏”),则按 __model__ / __user__ 分支处理。

  5. 把转换后的 chunk 重新注入到 body["messages"]。若希望 UI 中保留可点击的引用,请使用与 Open WebUI 内部一致的格式:

    <source id="1" name="filename.pdf" resource-id="<collection_id>" resource-type="collection">
    ...chunk text...
    </source>

    如果你不在意 UI 中的引用是否可点击,纯 Markdown 也能用——只有结构化的 <source> 形式才会把引用的弹出框接上。

  6. 返回 body。由于 file_handler 引发了文件引用被剥离,内置 RAG 步骤被跳过,LLM 调用会带着你清洗过的 chunk 直接发出。

注意:它对 filter 是“静态、全有或全无”的

file_handler 的读取是在 filter 加载时、模块级别只读一次。它不是 per-request 信号,不能在 inlet() 内部根据 model、user、chat 翻转。一旦设置,只要这个 filter 被调用,对应的请求就总会跳过内置 RAG——无论你那次 inlet() 是否真的执行了检索逻辑。

实际含义是:只要你用了 file_handler = True,你的 filter 就必须穷尽内置路径会处理的所有检索场景,包括那些你本来觉得用默认行为就行的场景。检索调用本身在两种路径下是相同的;只有“条件式的转换”(例如“只对远端模型脱敏”)按上下文分支。

如果你确实需要“同一模型上,部分用户走内置 RAG、部分用户走自定义 RAG”这种 per-request 切换,最干净的做法是:把自定义 RAG filter 套用 self.toggle = True,让它只在用户选中时运行——filter 没被选中时根本不跑,file_handler 也不生效,内置 RAG 正常处理请求。不要试图在 inlet() 里动态改 file_handler;这个标志在 inlet 被调用前就从模块对象上读走了。

与“在 inlet 中改 body['files']”相比,这为什么重要

另一种朴素做法是直接在 inlet() 里清空 body["metadata"]["files"] = []body["files"] = [] 来动态抑制内置 RAG。这在实践中是有效的,但很脆弱:未来 Open WebUI 版本可能会在新的键上挂更多文件/集合相关逻辑,而“我自己处理”的官方契约就是 file_handler。请优先使用文档化的开关。


⚙️ Filter 管理与配置

🌐 全局 Filter 与模型专属 Filter

Open WebUI 提供了一个灵活的多层级 Filter 系统,可让你控制哪些 Filter 处于激活状态、如何启用它们,以及谁可以切换它们。理解这个系统对高效管理 Filter 至关重要。

Filter 激活状态

Filter 在数据库中由两个布尔标志控制,可以处于四种状态之一:

状态is_activeis_global影响
全局启用TrueTrue自动应用于 所有 模型,且无法在单个模型上禁用
全局禁用FalseTrue不会应用到任何地方——尽管它被设为全局,Filter 本身仍处于禁用状态
模型专属TrueFalse只应用到管理员明确启用它的模型
未激活FalseFalse不会应用到任何地方,即便管理员已在模型上启用它——Filter 本身处于关闭状态
全局 Filter 的行为

当某个 Filter 被设为全局is_global=True)且激活is_active=True)时,它会对所有模型强制启用

  • 它会在每个模型的 Filter 列表中显示为已勾选且灰显
  • 管理员无法在模型设置中取消勾选它
  • 无论模型是什么,它都会在每一次聊天补全请求中运行

管理面板:将 Filter 设为全局

位置: 管理面板 → Functions → Filter 管理

要将 Filter 设为全局:

  1. 打开管理面板
  2. 点击侧边栏中的 Functions
  3. 在列表中找到你的 Filter
  4. 点击该 Filter 旁边的 三点菜单(⋮)
  5. 点击 🌐 地球图标 以切换 is_global
  6. 确保该 Filter 也处于激活状态(绿色开关)

API 端点:

POST /functions/id/{filter_id}/toggle/global

视觉标识:

  • 🟢 绿色开关 = is_active=True(Filter 处于激活状态)
  • 🌐 高亮的地球图标 = is_global=True(应用于所有模型)

🎛️ 双层 Filter 系统

Open WebUI 使用一个复杂的双层系统来按模型管理 Filter。乍看可能有点绕,但它的设计目标是同时支持始终启用的 Filter用户可切换的 Filter

第一层:FiltersSelector(哪些 Filter 可用?)

位置: 模型设置 → Filters → “Filters” 部分

它控制某个特定模型有哪些 Filter 可用

行为:

  • 显示所有 Filter(包括全局与模型专属)
  • 全局 Filter 会以已勾选且禁用的状态显示(不能取消勾选)
  • 普通 Filter 可以开关切换
  • 保存到数据库中的 model.meta.filterIds

示例:

{
  "meta": {
    "filterIds": ["filter-uuid-1", "filter-uuid-2"]
  }
}

第二层:DefaultFiltersSelector(哪些可切换 Filter 默认启用?)

位置: 模型设置 → Filters → “Default Filters” 部分

只有在至少选择了一个可切换 Filter(或它是全局 Filter)时,这一部分才会显示

用途: 控制哪些可切换 Filter 在新聊天中默认启用

什么是“可切换” Filter?

当某个 Filter 的 Python 代码包含以下内容时,它就变成可切换的:

class Filter:
    def __init__(self):
        self.toggle = True  # This makes it toggleable!

行为:

  • 只显示 toggle=True 的 Filter
  • 只显示以下两类 Filter:
    • filterIds 中(该模型已选中),或者
    • is_global=true(全局启用)
  • 控制聊天 UI 中该 Filter 默认是开启还是关闭
  • 保存到:model.meta.defaultFilterIds

示例:

{
  "meta": {
    "filterIds": ["filter-uuid-1", "filter-uuid-2", "filter-uuid-3"],
    "defaultFilterIds": ["filter-uuid-2"]
  }
}

解读:

  • 这三个 Filter 都对该模型可用
  • 只有 filter-uuid-2 会默认启用
  • 如果 filter-uuid-1filter-uuid-3toggle=True,用户就可以在聊天 UI 中手动启用它们

🔄 Toggleable Filters vs. Always-On Filters

理解这两类 Filter 的差异,是高效使用 Filter 系统的关键。

始终启用的 Filter(没有 toggle 属性)

特点:

  • 只要该 Filter 对某个模型处于激活状态,就会自动运行
  • 在聊天界面中没有用户控制
  • 不会出现在 “Default Filters” 部分
  • 不会显示在聊天集成菜单(⚙️ 图标)中

适用场景:

  • 内容审核 —— 过滤脏话、仇恨言论或不当内容
  • PII 清理 —— 协助隐藏邮箱、电话号码、SSN、信用卡号
  • 提示注入检测 —— 阻止试图操纵系统提示的行为
  • 输入/输出日志 —— 跟踪所有对话以便审计或分析
  • 成本跟踪 —— 估算并记录 token 使用量,便于计费
  • 限流 —— 对单个用户或全局执行请求限制
  • 语言约束 —— 确保回复使用指定语言
  • 公司政策执行 —— 注入法律免责声明或合规通知
  • 模型路由 —— 根据内容将请求重定向到不同模型

示例:

class ContentModerationFilter:
    def __init__(self):
        # 没有 toggle 属性——这是一个始终启用的 Filter
        pass

    async def inlet(self, body: dict) -> dict:
        # 在发送给模型前始终清理 PII
        last_message = body["messages"][-1]["content"]
        body["messages"][-1]["content"] = self.scrub_pii(last_message)
        return body

可切换的 Filter(toggle=True

特点:

  • 聊天输入栏中的可点击标签集成菜单(⚙️ 图标)中的开关两种形式出现。
  • 用户可以按聊天会话启用/禁用它们。选中状态保存在浏览器的 sessionStorage 中,不会持久化到服务器上的聊天记录。
  • 出现在模型的 “Default Filters” 配置中。
  • 模型上的 defaultFilterIds 控制初始选中状态(哪些可切换 Filter 在新聊天开始时默认开启)。
  • self.toggle 本身永远不会被运行时改写——它只是一个在请求分发时读取一次的可见性/门控标志。inlet() 只会在 Filter 当前被选中时执行,没有“else”分支可写。详见上面的说明

适用场景:

  • Web 搜索集成 —— 由用户决定何时搜索网页以获取上下文
  • 引用模式 —— 由用户控制何时在回复中要求来源
  • 详细/啰嗦模式 —— 用户在简洁和详细回复之间切换
  • 翻译 Filter —— 用户启用特定语言之间的翻译
  • 代码格式化 —— 用户决定何时应用语法高亮或 lint
  • 思考/推理开关 —— 用户通过启用/禁用 Filter 来切换底层模型的思考模式(在 inlet() 中无条件执行即可;用户通过移除标签来关闭)
  • Markdown 渲染 —— 在原始文本与格式化输出之间切换
  • 匿名化模式 —— 用户在讨论敏感话题时启用
  • 专家模式 —— 注入领域上下文(法律、医疗、技术)
  • 创意写作模式 —— 为创意任务调整 temperature 和风格

示例:

class WebSearchFilter:
    def __init__(self):
        self.toggle = True  # 设为用户可控
        self.icon = "https://example.com/icons/web-search.svg"

    async def inlet(self, body: dict, __event_emitter__) -> dict:
        # 只有当用户当前选中了该 Filter 时才会执行。
        # 不要在这里根据 self.toggle 做分支判断——能执行到这里时它必然是 True。
        await __event_emitter__({
            "type": "status",
            "data": {"description": "Searching the web...", "done": False}
        })
        # ... 执行网页搜索 ...
        return body

它们在界面中的位置:

  1. 模型设置 → Default Filters 部分
    • 管理员决定哪些可切换 Filter 在使用该模型的新聊天中默认处于选中状态。
  2. 聊天输入栏 → 内联标签
    • 对当前聊天中每个被选中的可切换 Filter 显示一个标签。
    • 点击标签会打开用户 valves 模态框(若该 Filter 定义了 UserValves),否则将该 Filter 从当前选中状态中移除(它会回到集成菜单,用户可以在那里重新启用)。
    • self.icon 会作为标签图片渲染。
  3. 聊天 UI → 集成菜单(⚙️ 图标)
    • 列出当前模型作用范围内所有可切换的 Filter,每个都有一个正式的开/关开关
    • 用于重新启用被从标签行移除的 Filter,或关闭默认选中的 Filter。

📊 Filter 执行流程

下面是从管理员配置到 Filter 执行的完整流程:

1. 管理面板(Filter 创建与全局设置)

  • 管理面板 → Functions → 创建新 Function
  • 设置 type="filter"
  • 切换 is_active(启用/禁用全局 Filter)
  • 切换 is_global(应用于所有模型)

2. 模型配置(按模型选择 Filter)

  • 模型设置 → Filters 部分
  • FiltersSelector:选择该模型可用的 Filter
  • DefaultFiltersSelector:设置默认启用状态(仅对可切换 Filter 生效)

3. 聊天 UI(用户交互——仅可切换 Filter)

  • 聊天 → 集成菜单(⚙️)→ 切换 Filter
  • 用户可以启用/禁用可切换 Filter
  • 始终启用的 Filter 会自动运行(没有 UI 控制)

4. 请求处理(Filter 编译)

  • 后端:get_sorted_filter_ids()
  • 获取全局 Filter(is_global=True, is_active=True
  • model.meta.filterIds 中加入模型专属 Filter
  • is_active 状态过滤
  • 对可切换 Filter:检查用户的启用状态
  • priority(来自 valves)排序

5. Filter 执行

  • 执行 inlet() Filter(请求前)
  • 把修改后的请求发送给 LLM
  • 执行 stream() Filter(流式过程中)
  • 执行 outlet() Filter(响应后)

📡 Filter 在 API 请求中的行为

当你直接使用 Open WebUI 的 API 端点(例如通过 curl 或外部应用)时,inlet()stream() 的执行模型与 WebUI 请求相同。真正不同的是 outlet():它在直接 API 调用时的行为会有明显差异,下面会详细说明。

关键行为差异

函数WebUI 请求直接 API — 稳定版(main直接 API — 预发布版(dev
inlet()✅ 始终调用✅ 始终调用✅ 始终调用
stream()✅ 流式期间调用✅ 流式期间调用✅ 流式期间调用
outlet()✅ 在响应后调用❌ 不会被 /api/chat/completions 调用——只会被 /api/chat/completed 调用⚠️ 仅在极窄条件下会内联运行(见下文)
__event_emitter__✅ 显示 UI 反馈⚠️ 对纯 API 调用无效⚠️ 对纯 API 调用无效
直接 API 调用时的 outlet() —— 准确情况

本页面的旧版本曾声称 outlet() 会在非流式的 /api/chat/completions 直接 API 请求过程中内联执行。该说法只在 dev 分支上部分成立,且依据后端源码验证后,真实情况是:

在打标签的发布版本 / main 分支上: outlet() 永远不会/api/chat/completions 调用。它只会在调用方再发起一次 /api/chat/completed 时才运行。需要 outlet() 的 API 集成必须同时发起这两个调用。

dev / 预发布版本上: outlet() 有可能在 /api/chat/completions 之后内联触发,但需要同时满足以下所有条件:

  1. 请求体中同时包含 chat_idid(助手消息 id)。缺一不可——否则后端会看到 event_emitter = None 并静默跳过 outlet 段。
  2. chat_id 对应的聊天记录必须属于当前已认证的用户;否则请求会在 outlet 路径执行前直接 404。(另一种做法:不传 chat_id 但传 parent_id: null,让服务器新建一个聊天。)
  3. 请求必须为非流式。在流式路径上,服务器会自己消费这个流,并把内容路由到用户的 WebSocket——HTTP 流式 API 调用方实际上什么也收不到。outlet() 在数据库侧仍会执行,但流式客户端看不到它的效果。

即便在非流式路径上,outlet() 也并不会改写 HTTP 响应体。Handler 只更新已持久化的聊天消息行,并通过 WebSocket 发出 chat:outlet 事件;返回给 HTTP 客户端的 JSON 仍是 outlet 之前的内容。要观察 outlet() 的输出,你必须重新读取聊天记录、订阅 WebSocket,或者改用 /api/chat/completed

给 Filter 作者的结论: 如果你的 Filter 的 outlet() 需要对纯 API 消费者(Continue.dev、Claude Code、Langfuse trace、自定义脚本等)可见,请把 /api/chat/completed 当作今天的受支持入口。当前仅靠 /api/chat/completions 期间的内联 outlet(),只对行为与 WebUI 一致的客户端才有效。

通过 __metadata__ 实现 Inlet ↔ Outlet 关联

__metadata__ 是一个贯穿整个请求生命周期的实时字典,因此你在 inlet() 中存放的任何值,都可以在同一请求outlet() 中看到——这对于记录开始时间、关联 ID、每次调用的临时 valves 很有用,前提是 outlet() 确实运行。鉴于上面的限制,这种模式主要用于 WebUI 请求以及 /api/chat/completed handler。

import time
from pydantic import BaseModel, Field

class Filter:
    class Valves(BaseModel):
        priority: int = Field(default=0)

    def __init__(self):
        self.valves = self.Valves()

    async def inlet(self, body: dict, __metadata__: dict = None) -> dict:
        if __metadata__ is not None:
            __metadata__["_my_started_at"] = time.monotonic()
        return body

    async def outlet(self, body: dict, __metadata__: dict = None) -> dict:
        if __metadata__ is not None and "_my_started_at" in __metadata__:
            duration = time.monotonic() - __metadata__["_my_started_at"]
            print(f"request took {duration:.3f}s")
        return body
inlet() 中伪造 chat_id / message_id 并不可靠

本页面的旧版本曾建议在 inlet() 内合成一个 local:<uuid>chat_id 和一个随机的 message_id,以便为纯 API 调用方强制触发 outlet()。请不要依赖这种写法——当前后端代码中,聊天归属检查会在 Filter 管道之前执行,并且即便内联 outlet 路径真的运行了,它也不会改写 HTTP 响应体。如果你的确需要把 outlet() 的输出通过 HTTP 暴露给 API 调用方,请改用 /api/chat/completed

为 API 调用方运行 outlet()/api/chat/completed

为直接 API 集成可靠地执行 outlet() 的做法是:在 /api/chat/completions 之后再发起一次 POST /api/chat/completed,把完整对话(包括助手回复)放在 messages 中传递。该端点会无条件执行 pipeline 的 outlet Filter 和 Function 的 outlet() handler,并把过滤后的负载作为返回结果。

# 第二步:在 /api/chat/completions 返回响应后运行 outlet()。
curl -X POST http://localhost:3000/api/chat/completed \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "llama3.1",
    "messages": [
      {"role": "user", "content": "Hello"},
      {"role": "assistant", "content": "Hi there! How can I help?"}
    ],
    "chat_id": "optional-chat-id",
    "session_id": "optional-session-id"
  }'
信息

dev 上该端点已被标记为 deprecated,倾向于内联执行;但由于内联执行并不会把过滤后的负载通过 HTTP 返回给纯 API 调用方,所以在今天的多数 API 集成中,/api/chat/completed 仍然是正确的选择。

区分 API 请求与 WebUI 请求

你可以通过检查 __metadata__ 参数来判断请求是来自 WebUI 还是直接 API 调用:

async def inlet(self, body: dict, __metadata__: dict = None) -> dict:
    # 判断请求是否来自 WebUI
    interface = __metadata__.get("interface") if __metadata__ else None
    
    if interface == "open-webui":
        print("Request from WebUI")
    else:
        print("Direct API request")
    
    # 也可以通过是否存在聊天上下文来判断
    chat_id = __metadata__.get("chat_id") if __metadata__ else None
    if not chat_id:
        print("No chat context - likely a direct API call")
    
    return body

示例:对所有请求做限流

由于 inlet() 始终会被调用,因此可以借助它实现对 WebUI 和 API 请求都生效的限流:

from pydantic import BaseModel, Field
from typing import Optional
import time

class Filter:
    class Valves(BaseModel):
        requests_per_minute: int = Field(default=60, description="每个用户每分钟的最大请求数")
    
    def __init__(self):
        self.valves = self.Valves()
        self.user_requests = {}  # 记录每个用户的请求
    
    async def inlet(self, body: dict, __user__: dict = None) -> dict:
        if not __user__:
            return body
        
        user_id = __user__.get("id")
        current_time = time.time()
        
        # 清理过期条目并统计近期请求
        if user_id not in self.user_requests:
            self.user_requests[user_id] = []
        
        # 只保留最近一分钟内的请求
        self.user_requests[user_id] = [
            t for t in self.user_requests[user_id] 
            if current_time - t < 60
        ]
        
        if len(self.user_requests[user_id]) >= self.valves.requests_per_minute:
            raise Exception(f"Rate limit exceeded: {self.valves.requests_per_minute} requests/minute")
        
        self.user_requests[user_id].append(current_time)
        return body

示例:记录所有 API 调用

对 WebUI 与直接 API 调用的 token 用量和请求进行记录:

from pydantic import BaseModel, Field
from typing import Optional
import logging

class Filter:
    class Valves(BaseModel):
        log_level: str = Field(default="INFO", description="日志级别")
    
    def __init__(self):
        self.valves = self.Valves()
        self.logger = logging.getLogger("api_usage")
    
    async def inlet(self, body: dict, __user__: dict = None, __metadata__: dict = None) -> dict:
        user_email = __user__.get("email", "unknown") if __user__ else "anonymous"
        model = body.get("model", "unknown")
        interface = __metadata__.get("interface", "api") if __metadata__ else "api"
        chat_id = __metadata__.get("chat_id") if __metadata__ else None
        
        self.logger.info(
            f"Request: user={user_email}, model={model}, "
            f"interface={interface}, chat_id={chat_id or 'none'}"
        )
        
        return body
事件发射器的行为

使用 __event_emitter__ 的 Filter 在 API 请求中也会执行,但既然没有 WebUI 来展示这些事件,状态消息就不会显示。Filter 的逻辑仍会运行——只是没有视觉反馈。


⚡ Filter 优先级与执行顺序

当多个 Filter 同时激活时,它们会按照 priority 的值顺序执行。理解这一点,对于构建 Filter 链——即后一个 Filter 需要依赖前一个 Filter 修改结果——的场景至关重要。

设置 Filter 优先级

通过 Valves 类中的 priority 字段来配置:

class Filter:
    class Valves(BaseModel):
        priority: int = Field(
            default=0,
            description="Filter 执行顺序。数值越小越先执行。"
        )
    
    def __init__(self):
        self.valves = self.Valves()
    
    async def inlet(self, body: dict) -> dict:
        # 该 Filter 的执行顺序取决于其 priority 值
        return body

优先级排序规则

优先级值执行顺序
0(默认值)最先执行
1在 priority 为 0 的之后执行
2在 priority 为 1 的之后执行
数值越小 = 越先执行

Filter 按 priority 升序排列。priority=0 的 Filter 会在 priority=1 之前执行,依此类推。当多个 Filter 共享同一个 priority 值时,会按函数 ID 的字母顺序排序,以保证顺序可预测。


🔗 Filter 之间的数据传递

当多个 Filter 同时启用时,每个 Filter 都会收到上一个 Filter 修改后的数据。某个 Filter 的返回值会成为下一个 Filter 的输入。

用户输入

模型路由 Filter(priority=0)→ 修改 body 的部分内容

Context Manager Filter (priority=1) → receives modified body ✓

Logging Filter (priority=2) → receives body with all previous changes ✓

LLM Request (sends final modified body to OpenAI/Ollama API)
Important: Always Return the Body

If your filter modifies the body, you must return it. The returned value is passed to the next filter. If you return None, subsequent filters will fail.

async def inlet(self, body: dict, __event_emitter__) -> dict:
    body["messages"].append({"role": "system", "content": "Hello"})
    return body  # Don't forget this!

🔌 注入额外的 API 请求体参数

Inlet Filter 可以向请求体中注入 额外字段,并将其转发给外部 LLM API。这对于 Open WebUI UI 中没有暴露出来、但 API 需要的特定参数非常有用。

请求体会从你的 inlet Filter 流向 LLM API,过程中不会剔除未知字段——只有 metadatafeaturestool_idsfilesskill_ids 这类内部键会被移除。你添加的其他字段都会被序列化为 JSON,并发送给 API 提供方。

示例:OpenAI 安全标识符

OpenAI 建议在每个请求中发送 safety_identifier 以便进行滥用检测。你可以通过 Filter 自动注入它:

import hashlib

class Filter:
    async def inlet(self, body: dict, __user__: dict = None) -> dict:
        if __user__ and __user__.get("id"):
            body["safety_identifier"] = hashlib.sha256(
                __user__["id"].encode()
            ).hexdigest()
        return body

哈希化后的用户 UUID 会作为顶层 body 参数被添加,并直接转发到 OpenAI 的 API——不会发送任何 PII,只会发送一个不可读的哈希值。

不能注入 HTTP 标头

Filter 只能修改 请求体form_data)。出站 HTTP 标头是单独构造的,不能由 Filter 影响。若要为 API 请求添加自定义标头,请使用 Admin Panel → Settings → Connections → OpenAI API 中的 headers 配置。

通过 Filter 注入 OpenAI 风格的 tools

Inlet Filter 还可以向 body["tools"] 中追加 OpenAI 风格的函数调用工具。这些工具会 Open WebUI 在服务端从 tool_ids、MCP 服务器以及模型内置工具中解析出的工具合并,而不会替换它们。

class Filter:
    async def inlet(self, body: dict) -> dict:
        body.setdefault("tools", []).append({
            "type": "function",
            "function": {
                "name": "lookup_user_tier",
                "description": "Return the caller's billing tier.",
                "parameters": {
                    "type": "object",
                    "properties": {"user_id": {"type": "string"}},
                    "required": ["user_id"],
                },
            },
        })
        return body

行为:

  • 原生函数调用(默认)。在 chat completion 发送前,由 Filter 注入的工具会被追加到服务端解析出的工具列表后。同一请求中,两套工具对模型都可见。
  • 由原始 API 调用方提供的工具。 如果进入 Filter 管道的请求中已经带有 body["tools"](例如外部客户端在调用 /api/chat/completions 时自带 tools 数组),那么这些由调用方提供的工具会优先,Open WebUI 会完全跳过服务端的工具解析。在这种情况下,Filter inlet 也应当以追加方式加入工具——调用方的工具和你的工具都会一起发送给 LLM。
  • 非原生函数调用。 服务端工具解析仍会走 Open WebUI 提示驱动的工具处理流程;由 Filter 注入的工具会被转发给 LLM,但这些工具的运行时执行者取决于你在上游调用中接入的实现。

这种能力适合那些在 workspace 中没有对应 Tool 的工具——例如,你希望只在特定用户的某些聊天里临时告诉模型它的能力。


🔍 Resolving the Base Model (__model__)

当用户选择 workspace 或自定义模型时,body["model"] 里保存的是自定义模型 ID(例如 "my-custom-gpt5"),而不是底层的基模型。要获取真正的基模型,请使用 __model__ dunder 参数:

class Filter:
    async def inlet(self, body: dict, __model__: dict = None) -> dict:
        custom_model_id = body["model"]  # e.g. "my-custom-gpt5"

        base_model_id = None
        if __model__ and "info" in __model__:
            base_model_id = __model__["info"].get("base_model_id")
            # e.g. "gpt-5.2"

        if base_model_id:
            print(f"Workspace model '{custom_model_id}' → base model '{base_model_id}'")
        else:
            print(f"Direct base model: '{custom_model_id}'")

        return body

如果没有 base_model_id,说明用户是直接选择了基模型(没有 workspace 包装层)。

可用的 Dunder 参数

Filter 可以在函数签名中声明以下任意参数,以自动接收它们:

参数提供什么
__model__完整的模型字典(对 workspace 模型还包含 info.base_model_id
__user__用户数据(idemailnamerole
__metadata__请求元数据(chat_idsession_idinterface 等)
__event_emitter__向客户端发送状态更新、嵌入等内容的函数
__chat_id__聊天会话 ID
__request__原始的 FastAPI Request 对象

只有你在函数签名中声明的参数才会被注入——Open WebUI 会在运行时检查签名,决定该传入什么。


🎨 UI 指示与视觉反馈

在管理员 Functions 面板中

指示含义
🟢 绿色开关Filter 处于激活状态(is_active=True
⚪ 灰色开关Filter 处于未激活状态(is_active=False
🌐 高亮地球图标Filter 是全局的(is_global=True
🌐 未高亮地球图标Filter 不是全局的(is_global=False

在模型设置中(FiltersSelector)

状态复选框说明
全局 Filter✅ 已勾选且禁用(灰显)“这个 Filter 已全局启用”
已选 Filter✅ 已勾选且可用“这个 Filter 已为该模型选中”
未选 Filter☐ 未勾选且可用“点击即可包含这个 Filter”

在聊天 UI 中(集成菜单)

元素说明
Filter 名称显示 Filter 的展示名称
自定义图标来自 self.icon 的 SVG 图标(如果提供)
切换开关为当前聊天启用/禁用该 Filter
状态徽标显示 Filter 当前是否处于激活状态

💡 Filter 配置最佳实践

1. 何时使用全局 Filter

适合使用全局 Filter 的场景:

  • 安全与合规(PII 清理、内容审核)
  • 全局格式统一(统一所有输出样式)
  • 日志与分析(跟踪所有请求)
  • 组织级策略(执行公司规范)

不建议使用全局 Filter 的场景:

  • 可选功能(改用可切换 Filter)
  • 模型特定行为(改用模型专属 Filter)
  • 用户偏好类功能(让用户通过开关控制)

2. 何时使用可切换 Filter

当满足以下情况时,应将 Filter 设为可切换(toggle=True):

  • 用户应该控制它何时启用(网页搜索、翻译)
  • 它是一个可选增强功能(引用模式、详细输出)
  • 它提供的功能并非用户每次都需要(代码格式化)
  • 它会带来性能成本,应该允许按需启用

以下情况不应将 Filter 设为可切换:

  • 它用于安全/合规(始终开启更合适)
  • 用户不应该能够关闭它(改用始终开启)
  • 它是系统级转换(全局更合适)

3. 为你的组织组织 Filter

推荐结构:

Global Always-On Filters:
├─ PII Scrubber (security)
├─ Content Moderator (compliance)
└─ Request Logger (analytics)

Model-Specific Always-On Filters:
├─ Code Formatter (for coding models only)
├─ Medical Terminology Corrector (for medical models)
└─ Legal Citation Validator (for legal models)

Toggleable Filters (User Choice):
├─ Web Search Integration
├─ Citation Mode
├─ Translation Filter
├─ Verbose Output Mode
└─ Image Description Generator

🎯 Key Components Explained

1️⃣ Valves Class (Optional Settings)

Think of Valves as the knobs and sliders for your filter. If you want to give users configurable options to adjust your Filter’s behavior, you define those here.

class Valves(BaseModel):
    OPTION_NAME: str = "Default Value"

For example: If you're creating a filter that converts responses into uppercase, you might allow users to configure whether every output gets totally capitalized via a valve like TRANSFORM_UPPERCASE: bool = True/False.

Configuring Valves with Dropdown Menus (Enums)

You can enhance the user experience for your filter's settings by providing dropdown menus instead of free-form text inputs for certain Valves. This is achieved using json_schema_extra with the enum keyword in your Pydantic Field definitions.

The enum keyword allows you to specify a list of predefined values that the UI should present as options in a dropdown.

Example: Creating a dropdown for color themes in a filter.

from pydantic import BaseModel, Field
from typing import Optional

# Define your available options (e.g., color themes)
COLOR_THEMES = {
    "Plain (No Color)": [],
    "Monochromatic Blue": ["blue", "RoyalBlue", "SteelBlue", "LightSteelBlue"],
    "Warm & Energetic": ["orange", "red", "magenta", "DarkOrange"],
    "Cool & Calm": ["cyan", "blue", "green", "Teal", "CadetBlue"],
    "Forest & Earth": ["green", "DarkGreen", "LimeGreen", "OliveGreen"],
    "Mystical Purple": ["purple", "DarkOrchid", "MediumPurple", "Lavender"],
    "Grayscale": ["gray", "DarkGray", "LightGray"],
    "Rainbow Fun": [
        "red",
        "orange",
        "yellow",
        "green",
        "blue",
        "indigo",
        "violet",
    ],
    "Ocean Breeze": ["blue", "cyan", "LightCyan", "DarkTurquoise"],
    "Sunset Glow": ["DarkRed", "DarkOrange", "Orange", "gold"],
    "Custom Sequence (See Code)": [],
}

class Filter:
    class Valves(BaseModel):
        selected_theme: str = Field(
            "Monochromatic Blue",
            description="Choose a predefined color theme for LLM responses. 'Plain (No Color)' disables coloring.",
            json_schema_extra={"enum": list(COLOR_THEMES.keys())}, # KEY: This creates the dropdown
        )
        custom_colors_csv: str = Field(
            "",
            description="CSV of colors for 'Custom Sequence' theme (e.g., 'red,blue,green'). Uses xcolor names.",
        )
        strip_existing_latex: bool = Field(
            True,
            description="If true, attempts to remove existing LaTeX color commands. Recommended to avoid nested rendering issues.",
        )
        colorize_type: str = Field(
            "sequential_word",
            description="How to apply colors: 'sequential_word' (word by word), 'sequential_line' (line by line), 'per_letter' (letter by letter), 'full_message' (entire message).",
            json_schema_extra={
                "enum": [
                    "sequential_word",
                    "sequential_line",
                    "per_letter",
                    "full_message",
                ]
            }, # Another example of an enum dropdown
        )
        color_cycle_reset_per_message: bool = Field(
            True,
            description="If true, the color sequence restarts for each new LLM response message. If false, it continues across messages.",
        )
        debug_logging: bool = Field(
            False,
            description="Enable verbose logging to the console for debugging filter operations.",
        )

    def __init__(self):
        self.valves = self.Valves()
        # ... rest of your __init__ logic ...

这里发生了什么?

  • json_schema_extraField 中的这个参数允许你注入 Pydantic 没有显式支持、但下游工具(例如 Open WebUI 的 UI 渲染器)可以使用的任意 JSON Schema 属性。
  • "enum": list(COLOR_THEMES.keys()):这告诉 Open WebUI,selected_theme 字段应该提供一组可选值,具体来说就是 COLOR_THEMES 字典中的键。随后 UI 会把下拉菜单渲染为“Plain (No Color)”“Monochromatic Blue”“Warm & Energetic”等可选项。
  • colorize_type 字段也演示了另一种用于不同着色方式的 enum 下拉菜单。

Valves 选项使用 enum 可以让 Filter 更易用,并防止无效输入,从而带来更顺滑的配置体验。


2️⃣ inlet 函数(输入预处理)

inlet 函数就像 烹饪前的备菜。想象你是一位厨师:在食材进入菜谱(这里相当于 LLM)之前,你可能会清洗蔬菜、切洋葱或给肉类调味。如果没有这一步,最后的菜品可能味道不足、食材没洗干净,或者风味不稳定。

在 Open WebUI 的世界里,inlet 函数会在用户输入发送给模型之前完成这项重要准备工作。它能确保输入尽可能干净、带有上下文,并且对 AI 来说更有帮助。

📥 输入

  • body:Open WebUI 传给模型的原始输入。它采用 chat-completion 请求的格式(通常是一个字典,包含对话消息、模型设置和其他元数据等字段)。你可以把它看作做菜时的食材。

🚀 你的任务: 修改并返回 body。LLM 处理的是修改后的 body,所以这是你为输入补充清晰度、结构和上下文的机会。

想转换 RAG chunk?inlet() 在检索之前运行

inlet() 时刻,body["metadata"]["files"]body["files"] 里只有文件/集合的引用——真正的 chunk 文本会在所有 inlet filter 全部返回之后才被取出并注入。如果你要检查或转换 chunk 本体(PII 脱敏、重排序、翻译、chunk 级 ACL),请参阅通过 file_handler 接管检索 了解官方支持的开关。

🍳 为什么要使用 inlet
  1. 添加上下文:自动为用户输入补充关键信息,尤其是在文本模糊或不完整时。例如,你可以添加“你是一位友好的助手”或“帮助用户排查软件 bug”。

  2. 格式化数据:如果输入需要特定格式,比如 JSON 或 Markdown,你可以在发送给模型前先做转换。

  3. 清理输入:去掉不需要的字符,删除可能有害或令人困惑的符号(如多余空白或表情符号),或者替换敏感信息。

  4. 简化用户输入:如果模型在获得额外指引后表现更好,你可以利用 inlet 自动注入澄清指令!

  5. 限流:跟踪每个用户的请求,并拒绝超出配额的请求(WebUI 和 API 请求都适用)。

  6. 请求日志:记录所有进入的请求,用于分析、调试或计费。

  7. 语言检测:检测用户语言,并注入翻译指令,或路由到对应语言的模型。

  8. Prompt Injection 检测:扫描用户输入中是否存在试图操纵系统提示词的行为,并阻止恶意请求。

  9. 成本估算:在发送给模型前估算输入 token,便于预算追踪。

  10. A/B 测试:根据用户 ID 或随机选择,将用户路由到不同的模型配置。

💡 示例用法:继续沿用“备菜”比喻
🥗 示例 1:添加系统上下文

假设 LLM 是一位厨师,要为意大利料理准备菜品,但用户没有说明“这是意大利菜”。你可以在把数据发送给模型之前附上这段上下文,让信息更明确。

async def inlet(self, body: dict, __user__: Optional[dict] = None) -> dict:
    # Add system message for Italian context in the conversation
    context_message = {
        "role": "system",
        "content": "You are helping the user prepare an Italian meal."
    }
    # Insert the context at the beginning of the chat history
    body.setdefault("messages", []).insert(0, context_message)
    return body

📖 会发生什么?

  • 任何类似“晚餐有什么好点子?”的用户输入,现在都会带上意大利主题,因为我们已经设置了系统上下文!芝士蛋糕也许不会出现,但意面大概率会。
🔪 示例 2:清理输入(移除奇怪字符)

假设用户输入看起来很乱,或者包含 !!! 之类的不需要的符号,这会让对话效率变低,也让模型更难解析。你可以在保留核心内容的前提下把它清理干净。

async def inlet(self, body: dict, __user__: Optional[dict] = None) -> dict:
    # Clean the last user input (from the end of the 'messages' list)
    last_message = body["messages"][-1]["content"]
    body["messages"][-1]["content"] = last_message.replace("!!!", "").strip()
    return body

📖 会发生什么?

  • 之前:"How can I debug this issue!!!" ➡️ 发送给模型后变成 "How can I debug this issue"
备注

注意:用户体验没有变化,但模型处理的是更干净、更容易理解的查询。

📊 inlet 如何帮助优化 LLM 输入:
  • 通过澄清含糊查询来提升 准确性
  • 通过移除表情、HTML 标签或多余标点等噪音,让 AI 更高效
  • 通过把用户输入格式化为模型预期的模式或 schema(例如某个特定场景下的 JSON),来确保一致性

💭 可以把 inlet 想象成厨房里的二厨——它负责确保送进模型(你的 AI“菜谱”)的一切都已经被备好、清理好并调味到位。输入越好,输出通常也越好!


🆕 3️⃣ stream 钩子(Open WebUI 0.5.17 新增)

🔄 什么是 stream 钩子?

stream 函数 是 Open WebUI 0.5.17 中新增的功能,允许你实时 拦截并修改流式模型响应

与会处理整段完成响应的 outlet 不同,stream 会在模型收到每个 单独片段 时逐个处理。

🛠️ 什么时候使用 stream 钩子?
  • 实时内容过滤 —— 在内容流式输出时屏蔽脏话或敏感内容
  • 实时词语替换 —— 替换品牌名、竞品名或过时术语
  • 流式分析 —— 实时统计 token 并跟踪响应长度
  • 进度指示 —— 检测特定模式以显示加载状态
  • 调试 —— 记录每个片段,排查流式问题
  • 格式修正 —— 在常见格式问题出现时立即修复
📜 示例:记录流式片段

下面演示如何检查并修改流式 LLM 响应:

async def stream(self, event: dict) -> dict:
    print(event)  # Print each incoming chunk for inspection
    return event

Example Streamed Events:

{"id": "chatcmpl-B4l99MMaP3QLGU5uV7BaBM0eDS0jb","choices": [{"delta": {"content": "Hi"}}]}
{"id": "chatcmpl-B4l99MMaP3QLGU5uV7BaBM0eDS0jb","choices": [{"delta": {"content": "!"}}]}
{"id": "chatcmpl-B4l99MMaP3QLGU5uV7BaBM0eDS0jb","choices": [{"delta": {"content": " 😊"}}]}

📖 会发生什么?

  • 每一行都代表模型流式响应中的一个小片段
  • delta.content 字段包含逐步生成的文本。
🔄 示例:从流式数据中过滤表情符号
async def stream(self, event: dict) -> dict:
    for choice in event.get("choices", []):
        delta = choice.get("delta", {})
        if "content" in delta:
            delta["content"] = delta["content"].replace("😊", "")  # Strip emojis
    return event

📖 之前: "Hi 😊" 📖 之后: "Hi"


4️⃣ outlet 函数(输出后处理)

outlet 函数就像一位 校对员:它会在 LLM 处理完之后,对 AI 的响应进行整理(或者做最后修改)。

📤 输入

  • body:这里包含聊天中的所有当前消息(用户历史 + LLM 回复)。

🚀 你的任务:修改这个 body。你可以清理、追加或记录变更,但要注意每一次调整对用户体验的影响。

💡 最佳实践

  • outlet 里,优先记录日志而不是直接修改内容(例如用于调试或分析)。
  • 如果需要大幅改写(例如格式化输出),可以考虑改用 pipe 函数
🛠️ outlet 的使用场景:
  • 响应日志 —— 跟踪所有模型输出,用于分析或合规
  • Token 使用跟踪 —— 在完成后统计输出 token,用于计费
  • Langfuse / 可观测性集成 —— 把 trace 发送到监控平台
  • 引用格式化 —— 重新整理最终输出中的引用链接
  • 免责声明注入 —— 追加法律声明或 AI 披露语句
  • 响应缓存 —— 将响应存储以便后续检索
  • 质量评分 —— 对模型输出执行自动化质量检查
Outlet 与 API 请求

对于直接的 /api/chat/completions 调用,outlet() 无法可靠运行。在标记发布版中,该端点从不会触发它;在 dev 中它可以内联运行,但前提是调用方提供 chat_id + id、拥有该聊天、并使用非流式请求——即便如此,过滤后的内容也不会返回到 HTTP 响应中。对于需要 outlet() 的直接 API 集成,请在调用 /api/chat/completions 后再发起 POST /api/chat/completed。完整情况请参阅 Filter Behavior with API Requests

💡 示例用法:把不希望用户看到的敏感 API 响应内容去掉:

async def outlet(self, body: dict, __user__: Optional[dict] = None) -> dict:
    for message in body["messages"]:
        message["content"] = message["content"].replace("<API_KEY>", "[REDACTED]")
    return body

🌟 Filter 实战:构建实用示例

我们来构建几个真实场景,看看 Filter 到底怎么用!

📚 示例 #1:为每个用户输入添加上下文

希望 LLM 始终知道自己是在帮助客户排查软件 bug 吗?你可以给每条用户查询都添加类似 “你是一名软件故障排查助手” 的指令。

class Filter:
    async def inlet(self, body: dict, __user__: Optional[dict] = None) -> dict:
        context_message = {
            "role": "system",
            "content": "You're a software troubleshooting assistant."
        }
        body.setdefault("messages", []).insert(0, context_message)
        return body

📚 示例 #2:高亮输出,便于阅读

如果想把输出返回成 Markdown 或其他格式化风格,可以使用 outlet 函数!

class Filter:
    async def outlet(self, body: dict, __user__: Optional[dict] = None) -> dict:
        # Add "highlight" markdown for every response
        for message in body["messages"]:
            if message["role"] == "assistant":  # Target model response
                message["content"] = f"**{message['content']}**"  # Highlight with Markdown
        return body

📚 示例 #3:流式可观测性提示(概念性模式)

管理员有时希望在模型流式输出过程中,从 UI 上获得轻量的提示——例如大致的首 token 延迟流式总时长粗略的 tokens/s——而无需另搭一套仪表盘或修改前端。Filter 完全可以靠前面介绍过的 stream 钩子做到这一点。

本节描述的是行为与注意要点,不是开箱即用的完整脚本。Filter 会执行任意 Python;请把任何你拼出来的片段都当作特权服务端代码对待。部署前请仔细审查,只通过可信渠道共享,并参考本页面顶部的关键安全警告

这种 Filter 应当观察什么

流式过程中,Filter 会收到一连串 event 对象(来自上游 provider / Open WebUI 栈的 chunk)。典型字段包括 choices[].delta(assistant 增量文本)、可选的 choices[].finish_reasonusage(通常只在终止 chunk 上出现),或合成的终止标志(一个 done 标志位、type 哨兵值)——具体形状会因 connector 与协议而异。

计时最简单的方式是用单调时钟(Python 里用 time.perf_counter()):记录第一次看到 assistant 回复流片段的时刻,对包含可打印模型文本(包括一些 API 暴露的 reasoning / 音频转写字段)的 delta 累加计数,并在你判定流已结束时对比时间戳。

关联一条流("这条流属于哪条消息?")

per-request 的 __metadata__stream 上的保留参数)里包含 chat_idmessage_id 等标识。请用复合键维护一个内存中的 map,以免并发聊天之间互相冲突。处理完一个键的流后,记得把已保存的状态清掉,防止 map 无限增长。

在 Web UI 中展示提示(status

UI 已经知道如何通过 status 负载显示与 assistant 消息绑定的简短进度文本。Filter 收到的 __event_emitter__:发射一个字典,type 设为 "status"data 中至少包含 description(人类可读文字)和 done(该状态行是否已结束)。如果你想在自己的工具里标识该插件,可选设置一个稳定的 data.action 字符串。

流中段发提示的粗略模式:

await __event_emitter__(
    {"type": "status", "data": {"done": False, "description": "First model text observed.", "hidden": False}}
)

在生成真正结束时再发一条 done: Truestatus 来汇总总耗时,否则 spinner 会一直转。

如何判定流真的结束了

务必保守:usage 字段可能在中途出现。建议在看到 finish_reason、显式的 stopevent["done"] 或协议相关的 DONE 标志时,再把这一轮回复视为结束——此时再发最终的 status 并清空已保存的状态。

大致吞吐

如果 usage.completion_tokens(或等效字段)在最后一个 chunk 上正常出现,可以用首段文本到现在经过的秒数算出 tokens ÷ 秒数

如果对某个 provider 来说 usage 要到末尾才出现——或者根本不会出现,许多 Filter 的兜底方案是按流式字符数粗略估算 token(经验法则做除法;很粗略且依赖模型)。不要把启发式得到的数字当作计费级别或科学基准使用。

与社区共享

把精心整理过的包发布到 openwebui.com 能让其他人用更少的步骤导入它。请只发布自己审计过的代码,并使用你可控的账号。


🚧 容易混淆的地方:清晰 FAQ 🛑

问:Filter 和 Pipe 函数有什么区别?

Filter 会修改进入模型从模型返回的数据,但不会明显介入这些阶段之外的逻辑。另一方面,Pipe:

  • 可以集成 外部 API,或者显著改变后端处理操作的方式。
  • 可以把自定义逻辑暴露成全新的“模型”。

问:我可以在 outlet 里做很重的后处理吗?

可以,但 这不是最佳实践

  • Filter 适合做轻量修改或日志记录。
  • 如果需要大幅改写,建议改用 Pipe Function

🎉 总结:为什么要构建 Filter 函数?

到这里,你已经学到:

  1. Inlet 负责处理 用户输入(预处理)。
  2. Stream 负责拦截并修改 流式模型输出(实时处理)。
  3. Outlet 负责调整 AI 输出(后处理)。
  4. Filter 最适合做轻量级、实时的数据流调整。
  5. 借助 Valves,你可以让用户动态配置 Filter,以适配不同需求。

🚀 轮到你了:开始动手试试吧!一个小小的调整,或者一段上下文补充,可能就能让你的 Open WebUI 体验更上一层楼。Filter 好玩、灵活,而且真的能让模型更强。

祝你编码愉快!✨

本内容仅供参考,不构成任何保证、担保或合同承诺。Open WebUI 按“现状”提供。请参阅您的许可协议 以了解适用条款。