NLWeb AgentFinder WHO Standalone Handler 深度解析:基于 REST 与 MCP 的高性能相关站点检索服务实战指南

原创2026-10-09 00:28:15766 阅读
文章标签:AI 应用MCP 服务AI Agent后端前端

NLWeb AgentFinder WHO Standalone Handler 深度解析:基于 REST 与 MCP 的高性能相关站点检索服务实战指南

本文以仓库 AgentFinder/README.md 为骨架,结合 AgentFinder/code 下的四个 Python 模块源码,完整讲解 NLWeb 生态中"WHO Standalone Handler"(独立 WHO 处理器)的架构设计、环境变量配置、REST / MCP / 管理三类接口的调用方式、后端可插拔扩展机制与生产部署方案。读者将能独立完成该服务的部署、调优、二次开发,并理解其"向量检索 + LLM 相关性打分 + 三级缓存"的核心执行链路。

一、什么是 WHO Standalone Handler

WHO("who can answer this")处理器的职责是:给定一个用户问题,从站点索引中找出最有可能回答该问题的站点并给出 0–100 的相关性得分与一句话说明。在主仓库中,该能力由 AskAgent/python/core/whoHandler.py(继承 NLWebHandler,并配合 AskAgent/python/core/whoRanking.py)实现,配套有前端页面 static/who.html、示例查询 static/sample-who-queries.js 以及缓存预热脚本 AskAgent/warm_up_who_cache.py 等。

AgentFinder 目录则将其独立重构为一个"高可用、模块化"的微服务形态:

  • 同时提供 REST 与 MCP(Model Context Protocol) 两种接口,便于普通 Web 客户端与 Agent / LLM 工具调用方接入;
  • 搜索后端可插拔:抽象了 Azure AI Search、Elasticsearch、Qdrant 等提供商;
  • LLM 后端可插拔:抽象了 Azure OpenAI、OpenAI、Anthropic Claude 等提供商;
  • 全异步(aiohttp)实现,内置三级缓存、并发控制与实时统计端点,适合直接放入生产环境。

二、架构总览:四个模块文件

README 明确指出系统由 4 个模块化 Python 文件组成(全部位于 AgentFinder/code):

文件 职责
agent_finder.py 基于 aiohttp 的 Web 服务器,注册 REST、MCP 与静态页面、管理端点,含 CORS 与全局错误中间件
who_handler.py 核心编排逻辑:嵌入缓存 → 检索 → 并行打分 → 过滤排序,内置 TTLCache 与统计
search_backend.py 可插拔检索接口(SearchBackend 抽象基类 + Azure 实现 + 工厂函数)
llm_backend.py 可插拔 LLM 接口(LLMBackend 抽象基类 + Azure OpenAI / OpenAI 实现 + 工厂函数)

请求执行主链路(对应 who_handler.py 的 process_query):

  1. 生成查询嵌入:调用 llm_backend.get_embedding(query),命中 embedding_cache 时直接复用;
  2. 向量检索:以 (query, vector, search_top_k) 调用 search_backend.search(),结果按查询文本的 MD5 缓存;
  3. 并行打分:对每个候选站点以 asyncio.as_completed 并行执行 _rank_site(),LLM 返回 {"score", "description"};
  4. 过滤与排序:剔除 score <= WHO_SCORE_THRESHOLD 的结果,按得分降序取前 WHO_MAX_RESULTS 条。

依赖清单见 requirements.txt:核心为 aiohttp>=3.9.0、asyncio>=3.4.3;Azure Search 需要 azure-search-documents>=11.4.0 与 azure-core>=1.29.0;Azure OpenAI / OpenAI 需要 openai>=1.10.0;Elasticsearch、Qdrant、Anthropic 依赖被注释为可选,按需启用。

三、快速开始

1. 安装依赖

pip install -r requirements.txt

2. 设置环境变量

# Search Backend Configuration
export SEARCH_PROVIDER=azure  # Options: azure, elasticsearch, qdrant
export SEARCH_ENDPOINT="https://your-search.search.windows.net"
export SEARCH_API_KEY="your-search-api-key"
export SEARCH_INDEX="nlweb_sites"

# LLM Backend Configuration
export LLM_PROVIDER=azure_openai  # Options: azure_openai, openai, anthropic
export LLM_ENDPOINT="https://your-openai.openai.azure.com"
export LLM_API_KEY="your-llm-api-key"
export LLM_MODEL="gpt-4"
export LLM_EMBEDDING_MODEL="text-embedding-3-large"
export LLM_MAX_CONCURRENT=50

# Optional: Server Configuration
export WHO_SERVER_PORT=8080
export WHO_SERVER_HOST=0.0.0.0

# Optional: WHO Handler Settings
export WHO_SCORE_THRESHOLD=70
export WHO_MAX_RESULTS=10
export WHO_SEARCH_TOP_K=50
export WHO_CACHE_TTL=3600

3. 启动服务

python agent_finder.py

服务默认监听 http://localhost:8080,启动时会打印各端点的访问地址(见 agent_finder.py 的 __main__ 入口)。

四、配置项详解:环境变量速查表

README 给出的完整配置表如下,其中标注了"Required"的变量未设置时对应后端初始化会直接抛出 ValueError(例如 search_backend.py 对 SEARCH_ENDPOINT / SEARCH_API_KEY 的校验)。

Variable Description Default
Search Backend
SEARCH_PROVIDER 检索后端提供商 azure
SEARCH_ENDPOINT 检索服务端点 Required
SEARCH_API_KEY 检索服务 API Key Required
SEARCH_INDEX 检索索引名 nlweb_sites
LLM Backend
LLM_PROVIDER LLM 提供商 azure_openai
LLM_ENDPOINT LLM 服务端点 Required
LLM_API_KEY LLM 服务 API Key Required
LLM_MODEL LLM 模型名 gpt-4
LLM_EMBEDDING_MODEL 嵌入模型名 text-embedding-3-large
LLM_MAX_CONCURRENT 最大并发 LLM 调用数 50
Server
WHO_SERVER_PORT 服务端口 8080
WHO_SERVER_HOST 服务主机 0.0.0.0
WHO Handler
WHO_SCORE_THRESHOLD 结果纳入的最小得分 70
WHO_MAX_RESULTS 返回结果上限 10
WHO_SEARCH_TOP_K 从检索取回的站点数 50
WHO_CACHE_TTL 缓存有效期(秒) 3600
WHO_MAX_CACHE_ENTRIES 检索缓存最大条目数 10000
WHO_RANKING_CACHE_ENTRIES 打分缓存最大条目数 100000

源码级默认值差异提示(务必注意)

对照源码,README 表格中部分默认值与代码实际取值存在差异,部署时建议显式设置以避免歧义:

  • WHO_SEARCH_TOP_K:README 写 50,但 who_handler.py 的 SETTINGS 实际默认是 30;
  • LLM_MAX_CONCURRENT:README 写 50,但 llm_backend.py 实际默认是 25;
  • SEARCH_INDEX:README 示例用 nlweb_sites,但 search_backend.py 中 SEARCH_INDEX 的代码默认值是 embeddings1536,而 nlweb_sites 是独立的 SEARCH_SITE 站点过滤参数(代码默认值);
  • 打分过滤条件:process_query 中实际使用严格大于 score > SETTINGS<a href="https://link.gitcode.com/i/570e8840405a9ec008c7f747bd2c862b" target="_blank">"score_threshold"] 才纳入结果(见 [who_handler.py)。

此外 who_handler.py 还支持两个 README 未列表的附加变量:

  • WHO_EARLY_THRESHOLD(默认 85):达到该得分的站点视为"高分结果",凑满 WHO_MAX_RESULTS 条即可提前返回;
  • WHO_RANKING_CACHE_ENTRIES(默认 100000):打分缓存的容量上限,其 TTL 固定为 WHO_CACHE_TTL * 2(打分结果比检索结果缓存更久,见 who_handler.py)。

LLM_CONFIG 中还有 LLM_API_VERSION(默认 2024-02-01,用于 Azure OpenAI 客户端,见 llm_backend.py)可供配置。

五、API 使用:REST 与 MCP 双通道

1. REST 端点 POST /who

README 示例:

curl -X POST http://localhost:8080/who \
  -H "Content-Type: application/json" \
  -d '{"query": "where can I buy running shoes?"}'

响应:

{
  "results": [
    {
      "name": "Nike.com",
      "url": "https://www.nike.com",
      "score": 95,
      "description": "Official Nike store with extensive running shoe collection"
    },
    {
      "name": "Adidas.com",
      "url": "https://www.adidas.com",
      "score": 92,
      "description": "Adidas official store featuring running footwear"
    }
  ],
  "query": "where can I buy running shoes?"
}

从源码看,/who 端点同时注册了 GET 与 POST(见 agent_finder.py):GET 通过 ?query= 取参,POST 从 JSON body 取 query;缺少 query 时返回 400,非法 JSON 返回 400,异常统一返回 500(agent_finder.py)。实际响应与 README 示例略有差异:源码将结果包装为 {"content": [{"type": "text", "text": json.dumps(results, indent=2)}], "isError": false},与 MCP 返回结构保持一致。

每个结果项的字段也更为丰富——除 name、url、score、description 外,还包含从站点 JSON-LD 中提取的 @type(如 Store、Organization 等,缺省为 "Site")以及固定的 api_version: "1.0"(见 who_handler.py 与 who_handler.py)。

2. MCP 端点 POST /mcp

MCP 端点遵循 JSON-RPC 2.0,协议版本为 2024-11-05(见 agent_finder.py)。README 给出的三步典型调用:

Initialize:

curl -X POST http://localhost:8080/mcp \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc": "2.0",
    "method": "initialize",
    "params": {"protocolVersion": "2024-11-05"},
    "id": 1
  }'

List Tools:

curl -X POST http://localhost:8080/mcp \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc": "2.0",
    "method": "tools/list",
    "id": 2
  }'

Call WHO Tool:

curl -X POST http://localhost:8080/mcp \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc": "2.0",
    "method": "tools/call",
    "params": {
      "name": "who",
      "arguments": {"query": "where can I buy running shoes?"}
    },
    "id": 3
  }'

源码中的协议细节(见 agent_finder.py):

  • initialize 返回 capabilities.tools(空对象)、serverInfo.name = "who-standalone"、serverInfo.version = "1.0.0" 及使用说明;
  • initialized / notifications/initialized:纯通知(无 id)时返回 HTTP 204,否则返回 {"status": "ok"};
  • tools/list 只暴露一个 who 工具,inputSchema 声明 query 为必填字符串;
  • tools/call 调用 who_handler.who_query(),成功返回 content 文本数组,失败返回 isError: true;未知工具返回 -32601(Method not found),缺少参数返回 -32602(Invalid params);
  • notifications/cancelled 支持请求取消通知(同样返回 204);
  • 非法 JSON 返回 -32700(Parse error),内部异常返回 -32603(Internal error)。

3. 管理端点

# Health Check
curl http://localhost:8080/health

# Statistics
curl http://localhost:8080/stats

# Clear Caches
curl -X POST http://localhost:8080/clear-cache

/health 会返回 {"status": "healthy", "stats": ...},底层不可用时返回 503(agent_finder.py);/clear-cache 依次清空嵌入、检索、打分三类缓存(who_handler.py)。

4. Web UI 与中间件

根路径 / 与 /index.html 提供内置调试界面 index.html(标题为 "WHO Handler - Site Finder",约 889 行的单文件 HTML/CSS/JS 页面)。服务同时挂载了 CORS 中间件(Access-Control-Allow-Origin: *,允许 GET/POST/OPTIONS)与全局错误中间件,避免向调用方泄露内部异常细节(agent_finder.py)。

六、核心工作原理:缓存、并行与提前返回

1. 三级缓存策略

WHOHandler 内部维护三类缓存(who_handler.py):

缓存 实现 TTL 容量
嵌入缓存 embedding_cache 普通 dict 永不过期(嵌入是稳定值) 无上限
检索缓存 search_cache TTLCache WHO_CACHE_TTL(默认 3600s) WHO_MAX_CACHE_ENTRIES(默认 10000)
打分缓存 ranking_cache TTLCache WHO_CACHE_TTL * 2 WHO_RANKING_CACHE_ENTRIES(默认 100000)

TTLCache 是自定义实现(who_handler.py):基于 OrderedDict 存 (value, timestamp),读取时校验 TTL 并将命中的键 move_to_end 实现 LRU 淘汰,容量满时 popitem(last=False) 逐出最旧条目。

2. 检索结果缓存键

检索结果以 hashlib.md5(query.encode()).hexdigest() 作为缓存键(who_handler.py);打分结果则以 (md5(query), site<a href="https://link.gitcode.com/i/0bca66c7335dcf6c4d4b37895a48aec6" target="_blank">"url"]) 为键([who_handler.py),保证同一站点对同一查询只打一次分。

3. 并行打分与提前返回

打分阶段对"未缓存"的站点逐个创建任务,用 asyncio.as_completed 边完成边收集(who_handler.py):

  • 每个任务完成即检查打分缓存,若得分 >= WHO_EARLY_THRESHOLD(默认 85)则计入 high_score_results;
  • 一旦高分结果凑满 WHO_MAX_RESULTS 条,立即按得分降序排序并提前返回,无需等待全部站点打分完毕——这是应对"慢 LLM 调用"的关键优化;
  • 打分失败(异常)的站点会被缓存为 score: 0 与错误说明,避免反复重试拖慢后续相同查询(who_handler.py)。

4. 全局单例与生命周期

who_handler 模块以模块级全局变量 _handler 保存单例,who_query()、get_stats()、clear_caches() 都经由 get_handler() 懒初始化获取实例(who_handler.py);agent_finder.py 在 on_startup 中预初始化后端、on_cleanup 中异步关闭检索与 LLM 客户端的连接池(agent_finder.py)。

七、检索后端机制:SearchBackend 抽象与 Azure 实现

search_backend.py 定义了抽象基类 SearchBackend,契约如下(README 原样保留):

class MySearchBackend(SearchBackend):
    async def initialize(self):
        # Initialize your client
        pass

    async def search(self, query: str, vector: List[float], top_k: int = 30) -> List[Dict[str, Any]]:
        # Return list of {"url", "json_ld", "name", "site"}
        pass

    async def close(self):
        # Cleanup connections
        pass

search() 必须返回 {"url", "json_ld", "name", "site"} 四字段的字典列表。当前仓库内置的实现情况:

  • AzureSearchBackend(默认,完整实现,见 search_backend.py):使用 aiohttp.TCPConnector(limit=50, limit_per_host=50) 建立连接池,通过 azure.search.documents.aio.SearchClient 发起纯向量检索(search_text=None),select 字段为 url / schema_json / name / site;若配置了 SEARCH_SITE 则追加 OData filter: site eq '<site>';向量查询落在 embedding 字段上、k=top_k。检索结果会将索引字段 schema_json 映射为接口规定的 json_ld。任何检索异常都会捕获并返回空列表(fail-open),而不是让请求崩溃。
  • ElasticsearchBackend / QdrantBackend(占位实现):initialize / search 直接 raise NotImplementedError,注释中给出了 AsyncElasticsearch 与 QdrantClient 的示例代码,作为后续实现的骨架。

工厂函数按 SEARCH_PROVIDER 分发(search_backend.py),未知提供商抛出 ValueError:

def get_search_backend() -> SearchBackend:
    if SEARCH_CONFIG["provider"] == "mysearch":
        return MySearchBackend()

八、LLM 后端机制:客户端池、并发信号量与打分提示词

llm_backend.py 定义抽象基类 LLMBackend:

class MyLLMBackend(LLMBackend):
    async def initialize(self):
        # Initialize your client
        pass

    async def get_embedding(self, text: str) -> List[float]:
        # Return embedding vector
        pass

    async def rank_site(self, query: str, site_json: str) -> Dict[str, Any]:
        # Return {"score": 0-100, "description": "..."}
        pass

    async def close(self):
        # Cleanup
        pass

AzureOpenAIBackend / OpenAIBackend(完整实现)的关键设计:

  • 客户端池 + 轮询:按 min(5, max_concurrent // 5) 创建 1–5 个异步客户端,用 itertools.cycle 轮询分配,规避单客户端限流(llm_backend.py);
  • 并发控制:asyncio.Semaphore(LLM_CONFIG<a href="https://link.gitcode.com/i/5ee941ded59204b2f8908c9d11e5a8f6" target="_blank">"max_concurrent"]) 限制同时进行的打分调用数([llm_backend.py);
  • 嵌入调用:embeddings.create 时对输入截断到 8000 字符;失败时返回 <a href="https://link.gitcode.com/i/a4f5a89ba548c53d58c4330534152059" target="_blank">0.0] * 1536 零向量(此类站点会被打低分,而不是中断整个查询,见 [llm_backend.py);
  • 打分提示词(Azure 版本,见 llm_backend.py):要求模型先判断"用户到底在找什么",再核对站点是否主营该类内容(例如买商品应导向售卖而非科普的站点),输出严格 JSON {"score": <integer 0-100>, "description": "<one sentence explanation>"};
  • 打分调用参数:temperature=0、max_tokens=100、response_format={"type": "json_object"},返回后对 score 做 max(0, min(100, int(score))) 钳制,缺字段补默认值;异常时返回 score: 0 与截断的错误描述(llm_backend.py)。

AnthropicBackend 为占位实现(注释给出 AsyncAnthropic 示例),且其文档明确提示:Anthropic 不提供嵌入服务,嵌入需搭配 OpenAI 系模型使用(llm_backend.py)。工厂函数 get_llm_backend() 同样按 LLM_PROVIDER 分发并校验未知值(llm_backend.py)。

九、性能优化

README 归纳的三方面优化与源码一一对应:

1. 缓存策略(三级)

嵌入缓存永不过期;检索缓存 TTL 化;打分缓存 TTL 加倍(见上文"核心工作原理")。

2. 并发控制

  • 检索侧:Azure 连接池上限 50(limit=50),超时 10 秒;
  • LLM 侧:客户端池 × 信号量双重约束,LLM_MAX_CONCURRENT 可调(代码默认 25,README 示例为 50);
  • 请求处理:全程 async,README 说明可支撑 50+ 并发请求;web.run_app(access_log=None, print=None) 关闭访问日志与内置启动横幅以降低开销(agent_finder.py)。

3. 内存占用(README 设计预期)

默认配置下:基础占用约 200MB;缓存写满约 2–4GB;调大缓存容量后可扩展至 16GB+。需要明确的是,这些数值是 README 给出的经验预期而非基准测试结果,实际占用取决于候选站点数与索引规模。

十、监控:/stats 实时指标

/stats 返回 get_stats() 的合并结果(who_handler.py),README 示例:

{
  "queries_processed": 1234,
  "cache_hits": 890,
  "cache_misses": 344,
  "total_sites_ranked": 10280,
  "embedding_cache_size": 567,
  "search_cache_size": 234,
  "ranking_cache_size": 8901
}

字段含义:queries_processed 累计查询数;cache_hits / cache_misses 检索缓存命中/未命中次数;total_sites_ranked 累计完成打分的站点数(+= len(ranking_tasks),仅统计当次新打分);三个 *_cache_size 为各缓存当前条目数,可据此判断缓存配置是否合理。

十一、Docker 部署

README 提供的 Dockerfile(注意:实际入口文件是 agent_finder.py,而非示例中的 server.py,请按仓库实际文件名调整):

FROM python:3.9-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY *.py .

CMD ["python", "agent_finder.py"]

构建与运行:

docker build -t who-handler .
docker run -p 8080:8080 --env-file .env who-handler

十二、生产部署:systemd 与 Nginx

systemd 服务

创建 /etc/systemd/system/who-handler.service(同样将入口替换为仓库实际的 agent_finder.py):

[Unit]
Description=WHO Handler Service
After=network.target

[Service]
Type=simple
User=www-data
WorkingDirectory=/opt/who-handler
EnvironmentFile=/opt/who-handler/.env
ExecStart=/usr/bin/python3 /opt/who-handler/agent_finder.py
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target

Nginx 反向代理

server {
    listen 80;
    server_name who.example.com;

    location / {
        proxy_pass http://localhost:8080;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_connect_timeout 10s;
        proxy_send_timeout 30s;
        proxy_read_timeout 30s;
    }
}

由于 /who 与 /mcp 都是 POST JSON 接口,Nginx 的超时参数(读写 30s)应能覆盖慢 LLM 打分的场景;如需 HTTPS,可在此基础上叠加 TLS 证书配置。

十三、故障排查

README 汇总的常见问题与处置建议:

  1. "No search results found":检查检索索引名与凭据是否正确,确认索引内确实存在数据(可在 Azure 门户或客户端直接查询验证);同时确认 SEARCH_SITE 过滤值与索引中的 site 字段取值一致。
  2. "Embedding error":核对 LLM_ENDPOINT、LLM_API_KEY 与 LLM_EMBEDDING_MODEL 是否与 Azure OpenAI 资源实际部署名一致;注意嵌入输入被截断到 8000 字符,超长内容属正常截断而非错误。
  3. 响应缓慢:检查 LLM_MAX_CONCURRENT 是否过小;通过 /stats 观察 cache_hits / cache_misses 判断缓存是否生效;必要时增大 WHO_MAX_CACHE_ENTRIES / WHO_RANKING_CACHE_ENTRIES 或调低 WHO_EARLY_THRESHOLD 让提前返回更快触发。
  4. 内存占用过高:通过环境变量调小缓存容量,并持续观察 /stats 中的三个 *_cache_size 指标;确认检索 top_k 没有设置得过大(候选站点越多,打分缓存膨胀越快)。

十四、与主 NLWeb 项目的关联与二次开发路径

从源码结构可以推断,AgentFinder 是主仓库 WHO 功能(AskAgent/python/core/whoHandler.py 及其配套的 whoRanking.py、前端 static/who.html)的独立可移植版本:主项目 WHO 处理器运行在 NLWeb 网关框架内,会剔除 site、prev 参数并把相对 URL 转换为网关地址;而 AgentFinder 以零框架依赖的 aiohttp 服务形态交付,同一套"问题 → 相关站点"能力可以被任何 REST / MCP 客户端直接消费。

二次开发建议:

  • 接入新检索源:在 search_backend.py 中实现 SearchBackend 三方法并在工厂函数注册 provider;
  • 接入新 LLM:在 llm_backend.py 中实现 LLMBackend 四方法并在工厂注册(需注意 Anthropic 场景下嵌入仍要依赖 OpenAI 系模型);
  • 调优打分提示词:直接修改各 LLM 实现中的 rank_site prompt,保持 {"score": 0-100, "description": "..."} 的 JSON 输出契约即可被 who_handler.py 无感消费。

十五、许可

AgentFinder 作为 WHO 处理器的独立实现,以 MIT License 发布(见 AgentFinder/LICENSE,Copyright (c) 2025 nlweb.ai),可自由用于商业与开源项目。更多问题可参考主 NLWeb 项目的文档(如 docs/nlweb-control-flow.md、docs/life-of-a-chat-query.md)了解 WHO 在完整问答链路中的位置。

登录后查看全文
NLWeb