前端转 AI · 第 06 篇|P5:产品化——Agent、多模态、私有化、计费

本文是系列第 6 篇,也是这个系列的收官篇。上一篇 P4:企业能力——多租户、SSO、审批流、连接器 把这个知识库升到了"企业级";再往前是 P3:工程化P2 质量P1 MVPP0 地基;整套六阶段路线见 总纲
P4 收尾时我留了一句"下一篇 [P5 产品化](待续) 会聊 Agent 多步检索、多模态、私有化与计费"。这篇就是那个"待续"——但它和前几篇有个本质区别:前 5 篇每一篇都交付了一个能跑的增量产品,而 P5 是"持续演进"阶段,没有"做完"的那一天。

阅读约定⚠️ 易错点 都是真实踩过的坑,紧跟的 ✅ 解决方案 可以直接照抄。代码已脱敏,但结构和参数与真实实现一致。本文刻意不美化落地度——哪块是真做了、哪块只有底座、哪块压根没碰,都会标清楚。


0. 这篇的定位:P5 不是"功能清单",是"持续演进的纪律"

先泼一盆冷水。很多人一到 P5(产品化)就容易变成"课程收藏家"——看了一堆 Agent 教程、收藏了十个多模态仓库,但产品一点没动。

⚠️ 路线级易错点:到了产品化阶段,容易陷入"这个也想要、那个也该做",最后什么都没落地。
解决方案(来自总纲,我严格执行了):每一项立项前先写一句用户故事和一条验收标准,写不出来就说明现在不需要做它。 所以这一篇我不按"功能清单"写,而按"用户故事 + 真实落地度"写。

P5 我给自己列了四块,每一块都先问"谁会用、用了解决什么":

能力块 用户故事(一句话) 验收标准
Agent "问复杂问题时,系统能自己多查几次、调工具算数、最后给带引用的答案" 多步检索可观测、工具调用有白名单、答案强制带引用/拦敏感
多模态 "扫描件 PDF、带表格的制度文件也能问答" OCR 入库可被检索;表格问题走精确答、不瞎编数字
私有化 "客户机房不能出公网,要跑本地模型" 无云 API Key 也能注册→建库→入库→问答闭环
计费 "老板问这个月花了多少 token、哪个租户超量了" 用量可统计、文档/连接器有硬上限、能看 7 日图表

下面逐块拆,并如实标注落地度。先放一张全局图,看这四块挂在哪:

                ┌──────────────────────────────────────────────┐
                │   Next.js 管理台                              │
                │   ChatPanel( na|advanced|agent )             │
                │   IngestProfile(OCR/表格开关) / UsageChart    │
                └───────────────────────┬──────────────────────┘
                                        │ Cookie(httpOnly)
                ┌───────────────────────▼──────────────────────┐
                │              FastAPI (api)                    │
                │  ├─ orchestration/agent  AgentRuntime+planner │
                │  │     tools/ 白名单: retrieve|calculate|     │
                │  │            summarize|table_query           │
                │  ├─ services/ocr        Tesseract(可插拔)      │
                │  ├─ services/table_*    表格抽取+精确问答       │
                │  ├─ services/providers  ollama|openai_compat  │
                │  └─ services/usage/quota 计量+硬配额+限流       │
                └───┬────────────────┬───────────────┬──────────┘
                    │                │               │
            ┌───────▼─────┐  ┌───────▼──────┐ ┌───────▼──────┐
            │ PostgreSQL  │  │   Chroma     │ │  Ollama(可选) │
            │ UsageRecord │  │             │ │ qwen2.5:7b   │
            │ 配额字段     │  │             │ │ nomic-embed  │
            └─────────────┘  └──────────────┘ └──────────────┘

一句话概括 P5 的边界:能力都长出来了,但"默认路径"仍然保守——Agent 默认单步、OCR 默认关、私有化要切 provider、计费只到计量层。这是有意的"渐进式产品化",不是技术债。


1. Agent:多步检索、工具调用、护栏

1.1 编排层到这一步才真正长成

总纲第六节早就剧透过:P0/P1 两条流是硬编码直线,P2 才建 orchestration/ 把问答链路节点化,P3 把入库链路搬成工作流,P4 把流配置化,"P5 才轮到 Agent 多步编排"。现在终于到这一步。

这一篇的 Agent 不自己造引擎。编排内核是 AgentRuntime,跑的是 plan → act → observe 事件循环:

# backend/app/orchestration/agent/runtime.py(节选)
class AgentRuntime:
    def __init__(self, planner=None, max_steps=None, *,
                 require_citations=False, block_sensitive=False):
        self.planner = planner or default_planner      # ← 注意默认 planner
        self.max_steps = max_steps or settings.agent_max_steps  # 默认 6

    def stream(self, message, history, ctx):
        yield {"type": "agent_step", "step": 0, "phase": "plan"}
        for step in range(1, self.max_steps + 1):
            decision = self.planner(message, history, observations)
            if decision.get("type") == "answer":
                err = check_answer_guards(content, citations, ...)
                if err: yield {"type": "error", "content": err}; return
                yield {"type": "done"}; return
            result = run_tool(decision["tool"], decision["args"], ctx)
            observations.append(result.observation)
            citations.extend(result.citations)

每个 step 都 yield 一个 agent_step 事件(plan / act / observe + elapsed_ms),前端 AgentStepTimeline.tsx 只读渲染成"这次回答走了哪些节点、各花了多久"——这就是总纲说的"只读流程图",成本只有 Dify 画布的 1/50,没做拖拽画布。

1.2 真实落地度:三样东西是扎实的

① 工具白名单(绝不裸奔)。 tools/registry.py 只放行 4 个工具:retrieve / calculate / summarize / table_queryretrieve_tool 强制 session_kb_id,拒绝跨库 kb_id——Agent 再怎么"想",也读不到别人的知识库。calculate_toolast 做安全求值,只放行 + - * /,不 eval 任意表达式。

② 护栏(默认双开)。 guards.pycheck_answer_guards 默认开 require_citations(无引用直接 insufficient evidence)和 block_sensitive(PII 正则 + 敏感词)。config.py 里:

agent_max_steps = 6
agent_require_citations = True
agent_block_sensitive = True
agent_sensitive_words = "密码,secret,api_key,credential,token"

③ 前端可选模式。 ChatPanel.tsx 的聊天模式下拉里有 agent 这一项,用户自己切;不是偷偷替换默认链路。

1.3 ⚠️ 易错点:默认是单步,多步 ReAct 没接默认路径

这是写这篇时我最想讲清的一个"诚实点"。看 default_planner

# backend/app/orchestration/agent/planner.py
def default_planner(message, history, observations):
    """Default: one retrieve then answer (simple fallback without LLM)."""
    if not observations:
        return {"type": "act", "tool": "retrieve", "args": {"query": message}}
    return {"type": "answer",
            "content": observations[-1] if observations else "No evidence found."}

没有任何 observation 就 retrieve 一次,有了 observation 直接 answer——也就是说生产默认是"带护栏的单步 retrieve",跟 P1 的 naive_rag 行为几乎一样。而真正的 LLM 多步规划器 llm_planner 其实已经写好了

def llm_planner(message, history, observations, *, cfg=None, llm=None):
    """Production planner: ask LLM for structured next step JSON."""
    llm = llm or build_llm(cfg)
    prompt = f"{_PLANNER_SYSTEM}\n\nUser question: {message}\n\nObservations:\n{obs_block}\n\nNext step JSON:"
    raw = str(llm.complete(prompt)).strip()
    parsed = _extract_json(raw)
    if parsed and parsed.get("type") in ("act", "answer"):
        return parsed
    return {"type": "answer", "content": raw or "Unable to plan next step."}

⚠️ 易错点llm_planner 函数完整、单测用 scripted_planner 也验证过循环逻辑,但全仓除自身定义外没有任何生产调用——chat_stream 构造 AgentRuntime 时不传 planner,于是默认走 default_planner。结果是:多步 ReAct 能力"已具备但未默认开启",没有开关、没有文档入口、没有测试覆盖。

解决方案:要体验真正的多步 Agent,把 planner 注入即可(最小改动,不用改运行时):

# 在 routes_chat.py 的 agent 分支里
from app.orchestration.agent.planner import llm_planner
from app.orchestration.agent.runtime import AgentRuntime

runtime = AgentRuntime(
    planner=llm_planner,                 # ← 关键:注入 LLM 规划器
    require_citations=True,
    block_sensitive=True,
)

我把它明确写成"待接开关"而不是藏着——因为生产默认单步是有意的:多步会多烧 token、多一次延迟,且 LLM 规划本身会出错。先让单步稳稳跑,多步作为"可开启的增强",比"默认就多步、线上抖动"稳得多。呼应 P4 易错点 16:别把 IngestProfile 当可视化 flow graph 编排,范围会爆炸——Agent 也一样,先有白名单+护栏的"可控多步",再谈智能。

验收点test_agent_runtime.py(plan→act→observe 循环、max_steps 截断)、test_guards.py(无引用拦截、敏感词拦截)、test_tools_registry.py(跨库 retrieve 被拒、calculate 只放行四则运算)均通过。


2. 多模态:OCR + 表格问答(图片理解未做)

2.1 OCR:把 P0 就埋的"可能是扫描件"提示变成真能力

还记得吗?P0 第 677 行我写过:"报错信息里写清『可能是扫描件、需要 OCR』……扫描件的 OCR 留到 P5。"P1 入库校验空文本也会报 "may be a scanned PDF needing OCR",但那时 OCR 本身没做。现在 P5 把它补上了。

OCR 走的是可插拔引擎协议,测试用 FakeOcr 替身,生产用 Tesseract:

# backend/app/services/ocr/base.py
class OcrEngine(Protocol):
    def ocr_pdf_pages(self, path: Path) -> list[tuple[int, str]]:
        """Return (1-based page number, recognized text) for each page."""
# backend/app/services/ocr/tesseract_engine.py(节选)
class TesseractOcrEngine:
    def ocr_pdf_pages(self, path):
        doc = fitz.open(path)                      # PyMuPDF 逐页渲染成图
        for i, page in enumerate(doc, start=1):
            pix = page.get_pixmap(dpi=300)
            img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
            text = pytesseract.image_to_string(img, lang="chi_sim+eng")
            yield (i, text)

入库接线在 ingest.py_resolve_ocr_engine + ocr_enabled 开关:空文本且开了 OCR → 走 Tesseract 回填;仍空才报"可能是扫描件"。

⚠️ 易错点:OCR 依赖宿主机装 Tesseract 二进制 + pip install pymupdf pytesseract pillow,新手一跑就 TesseractNotFoundError。更坑的是——有些人图省事在缺依赖时静默降级,结果扫描件被当空文件、悄悄丢知识。
解决方案:本项目缺依赖时显式报错、不静默降级(这点我专门做对了)。部署文档里写清安装命令:

# Windows(示例,Linux/macOS 用对应包管理器)
# 1) 装 Tesseract 主程序并加入 PATH
# 2) 装中文包(chi_sim)
# 3) pip install pymupdf pytesseract pillow

⚠️ 易错点(质量预期):中文扫描件用默认 Tesseract 准确率有限,尤其表格线、印章、手写体。
解决方案:文档明确把 PaddleOCR 列为后置、非必装;先用 Tesseract 把"能 OCR"跑通,精度不够再换引擎——不要在第一天就纠结识别率,先把链路打通。

2.2 表格问答:结构化精确答,不烧 LLM

制度文件里全是"附表三:各职级年假天数"。这种问题让 LLM 从 chunk 里"读数字"极易算错。本项目的解法是把表格切成独立 chunk + 结构化工具短路

# backend/app/services/table_extract.py
def extract_table_nodes_from_pages(pages):
    # 把空格/管道对齐的表格切成 type="table" 的 chunk,带 schema_hint
    ...

# backend/app/services/table_answer.py
def try_table_short_answer(question, table_chunks):
    # 命中表格 chunk 时,不走 LLM,直接精确解析返回
    # 支持:求和 / 第 N 行 / 按列查
    ...

naive_rag.py 里接了 try_table_short_answer:一旦检索命中的是表格 chunk,就走结构化解析,双路短路——既准又省 token。Agent 侧也有 table_query_tool 复用同一套逻辑。

⚠️ 易错点:把"表格"当普通文本 chunk 塞进向量库 → 数字被切散、行列关系丢失,问答时 LLM 凭印象编数("大约 10 天"变成"15 天")。
解决方案:表格独立成 type="table" chunk + 结构化查询工具。前端 IngestProfileForm.tsx 里 OCR 与"表格结构化抽取"是两个独立 checkbox,按需开。

2.3 图片理解(vision):明确未做

必须说清楚:图片/多图语义理解(让模型"看"图回答)目前全仓没有任何实现。grep vision|image_understand|图片理解 只命中模型名配置字符串,没有任何视觉理解代码。

所以本项目的"多模态"范围是文本化(OCR)+ 结构化表格,不含图像语义理解。这不是疏忽,是范围选择:图片理解通常要 vision 模型(如带视觉的本地模型),在 L1 离线栈里成本和可靠性都还没到能承诺的程度。写进路线图、暂不立项。


3. 私有化 / 离线部署:L1 已可跑通

3.1 docker-compose.offline.yml:无云 Key 闭环

这是 P5 里最实在、最容易演示成功的一块。客户说"我们机房不能出公网",过去你会卡在"模型在云上"。现在用 docker-compose.offline.yml整套闭环跑本地 Ollama,不需要任何外部 API Key

# docker-compose.offline.yml(节选)
services:
  ollama:
    image: ollama/ollama
    ports: ["11434:11434"]
  api:
    environment:
      - LLM_PROVIDER=ollama
      - EMBEDDING_PROVIDER=ollama
      - OLLAMA_BASE_URL=http://ollama:11434/v1
      - OLLAMA_LLM_MODEL=qwen2.5:7b
      - OLLAMA_EMBED_MODEL=nomic-embed-text
      - OPENAI_API_KEY=ollama          # 占位即可,不指向真实云服务
  worker:
    environment:                       # 与 api 同套 ollama 配置
      - LLM_PROVIDER=ollama
      - ...

provider 切换在 services/providers.py 真实生效——build_llmchat_model_name / chat_api_base / provider_api_keyollamaopenai_compatible 两套走不同分支:

⚠️ 易错点:Ollama 没起来(镜像没 pull、端口被占)时,API 卡在等模型响应,前端转圈不报错,排查极痛苦。
解决方案providers.pyLocalModelUnreachableError + maybe_raise_local_model_error,Ollama 不可达时 API 直接返回 503 + 清晰提示,已在 routes_chat.py 捕获。第一次跑要先 ollama pull qwen2.5:7b(需联网一次),运行期不再依赖外部 LLM——这正是"私有化"的边界。

离线手册 docs/deploy/offline.md 给了硬件建议、拉模型脚本、IdP 降级(L1 用本地账号、不启 Keycloak)、性能对照表。Lean 一句话:L1 = 本地账号 + 本地模型,能跑通"注册→建库→上传→本地 embedding 入库→本地 LLM 问答"完整闭环。

3.2 边界与刻意延后

⚠️ 易错点:把"能跑通离线"误宣传成"满足等保/气隙交付",客户一验收就翻车。
解决方案:文档自陈把 L2(气隙镜像仓库、离线 apt/pip 源、密钥托管)L3(完整内网 IdP 交付包、多 AZ、等保) 列为"不在 L1 交付",需另开规格。私有化的"能跑"已达,政企级"全交付"未达——能说清"交付到哪一层",比拍胸脯强

注意:离线 compose 里 Keycloak 是 NOT included 的(注释写明"用本地账号")。要企业 SSO 还是得走 P4 的 OIDC 方案,离线场景先不接 IdP。


4. 计费:计量 + 硬配额底座在,但"商业化"还早

4.1 已落地的部分(骨架是好的)

用量表结构很清楚:

# backend/app/models/orm.py
class UsageRecord:
    tenant_id / user_id / kb_id
    tokens_in / tokens_out / cost

usage_service.py 三件套:record() 写行、compute_cost()MODEL_PRICING 算钱、stats_last_7_days() 给管理台聚合。前端 UsageChart.tsx + admin/page.tsx 渲染 7 日用量图,/api/admin/stats 出数据。

配额是硬上限且部分已接线:

# backend/app/services/quota_service.py
def assert_can_add_document(kb_id, ...): ...   # ingest.py:558 已调用
def assert_can_add_connector(kb_id, ...): ...   # connector_service.py:99 已调用
def assert_daily_token_budget(tenant_id, ...): ...  # ⚠️ 定义了,全仓零调用

限流也真实生效:core/rate_limit.py(slowapi)auth=5/minuteapi=20/minute

4.2 ⚠️ 两个真实缺口(写作必须如实写)

⚠️ 缺口 1——流式对话不计量usage_service.record 只在同步 chat 端点调用;而前端实际走的是 POST /api/chat/stream,该流式端点完全没有 record,flows 内部也不记录。结论:用户真实对话产生的 token 用量基本没落库,管理台图表大概率长期空/不全。
解决方案(路线):在 SSE flow 的结束事件里补一次 record(用 extract_token_usage 从 response 元数据取 prompt/completion tokens),一个拦截点就能堵上。

⚠️ 缺口 2——日 token 配额从未接线assert_daily_token_budget 写了却没人调用,形同虚设。orm.pymax_daily_tokens=1_000_000 这个默认值只是个标量列。
解决方案(路线):把 assert_daily_token_budget 接到 chat_stream 入口,tenant_id 维度统计当日已用、超了直接 429。代码量很小,只是当时没排进验收。

4.3 ❌ 没有"套餐 / tier"体系

这点要讲透,免得被当成"已上线计费系统":

  • 没有 免费 / 专业 / 企业 这类分层;
  • 没有 订阅、出账、支付、发票;
  • compute_cost 只是把一个数写进 cost 字段,没有对应的账单、配额档位、超量策略

所以所谓"计费"目前是"计量 + 两个硬上限(文档数/连接器数)+ 限流",离"商业化计费系统"还差套餐体系、支付对接、超量策略三座大山。把它叫"用量可观测与配额护栏"更准确。

呼应总纲 P5 的"商业化:配额、计费、多环境"——配额已做、计费(计量层)已做、真正的商业化计费是下一步。不夸大,是因为面试或客户面前,说"我们有计费"和"我们有用量统计+硬配额"是两码事。


5. P5 的四点核心教训 + 刻意不做的事

P5 这一路的四句话,记牢:

  1. Agent 先"可控"再"智能"——白名单 + 护栏 + 单步默认,多步作为可开启增强,比默认多步稳。
  2. 多模态先做"文本化"——OCR + 表格结构化是性价比最高的两步,图片理解延后。
  3. 私有化讲清"交付到第几层"——L1 离线闭环能演示,L2/L3 另开规格,别拍胸脯。
  4. 计费别叫早——计量+硬配额是底座,套餐/支付/出账才是商业化,分清楚再对外说。

刻意不做的事(和 P4 的"WORM 哈希链"一样,能说清"为什么不做"比硬上更重要):

  • 拖拽画布(总纲 6.6:成本是 Dify 的几万行,只读流程图替代,1/50 成本);
  • 图片理解 / vision(L1 离线栈成本与可靠性未到承诺程度);
  • 套餐 / 订阅 / 支付(超出"用量+硬配额"范围,属独立商业化项目);
  • 跨库对比检索(RetrieveTool 解锁其它 kb_id,明确留作 5.1b 不合入必验)。

6. 收官:六阶段一路走来

从 P0 到 P5,这个知识库从一行 CLI 跑通 切分→向量化→检索→生成,长成了:

P0 地基    一行 CLI 跑通 RAG 闭环(37 个坑)
P1 MVP     FastAPI + Next.js,能演示的知识库(SSE 流式问答)
P2 质量    混合检索 + rerank + Golden Set 评测(策略可切换对比)
P3 工程化  异步入库(ARQ) + KB级RBAC + 发布门禁 + 审计可观测
P4 企业能力 多租户 + SSO(OIDC/PKCE) + 审批流 + 连接器
P5 产品化  Agent(可控多步) + 多模态(OCR/表格) + 私有化(L1) + 计费(计量/配额)

作为前端转 AI 的人,这套路线给我的真正收获不是某个 API 怎么调,而是三件事:

  • 垂直切片 + 螺旋升级是真的——每个阶段都交付一个能跑的增量,永远不会"两个月啥也没有"。
  • 抽象是长出来的,不是设计出来的——编排层 P2 才建、Agent P5 才多步,早一步都是债。
  • 诚实标注落地度——哪块真做了、哪块只有底座,比"全栈都行"经得起面试官和客户的追问。

如果你也正从前端往 AI 走,我的建议是:把这套当脚手架,按你自己的业务场景,用"用户故事 + 验收标准"从 P5 四块里挑一块先做。别贪全,先做透一块,比收藏十个教程强。

到这里,六篇系列正式完结。感谢一路看到这里的你——P0 的 37 个坑、P2 的评测、P4 的租户隔离、P5 的"可控多步",希望它们让你少走点弯路。有问题、有不同做法,欢迎评论区交流,我会回来回复。

(系列总目录见 总纲;逐篇链接已分别在各自开篇的"上一篇"处回填。)

posted @ 2026-08-17 18:00  南珂丶一梦  阅读(6)  评论(0)    收藏  举报