NLWeb AgentFinder WHO Standalone Handler 深度解析:基于 REST 与 MCP 的高性能相关站点检索服务实战指南
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):
- 生成查询嵌入:调用
llm_backend.get_embedding(query),命中embedding_cache时直接复用; - 向量检索:以
(query, vector, search_top_k)调用search_backend.search(),结果按查询文本的 MD5 缓存; - 并行打分:对每个候选站点以
asyncio.as_completed并行执行_rank_site(),LLM 返回{"score", "description"}; - 过滤与排序:剔除
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则追加 ODatafilter: 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 汇总的常见问题与处置建议:
- "No search results found":检查检索索引名与凭据是否正确,确认索引内确实存在数据(可在 Azure 门户或客户端直接查询验证);同时确认
SEARCH_SITE过滤值与索引中的site字段取值一致。 - "Embedding error":核对
LLM_ENDPOINT、LLM_API_KEY与LLM_EMBEDDING_MODEL是否与 Azure OpenAI 资源实际部署名一致;注意嵌入输入被截断到 8000 字符,超长内容属正常截断而非错误。 - 响应缓慢:检查
LLM_MAX_CONCURRENT是否过小;通过/stats观察cache_hits / cache_misses判断缓存是否生效;必要时增大WHO_MAX_CACHE_ENTRIES/WHO_RANKING_CACHE_ENTRIES或调低WHO_EARLY_THRESHOLD让提前返回更快触发。 - 内存占用过高:通过环境变量调小缓存容量,并持续观察
/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_siteprompt,保持{"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 在完整问答链路中的位置。