优化(模型传输): 移除强制短连接并以执行作用域复用HTTP客户端
This commit is contained in:
parent
1571abad43
commit
cd8914f1f4
@ -197,7 +197,9 @@ class 应用装配:
|
|||||||
)
|
)
|
||||||
return self.数据库
|
return self.数据库
|
||||||
|
|
||||||
def 要求模型执行器(self, 上下文: 执行上下文, 计价: 调用计价 | None) -> 模型执行器:
|
def 要求模型执行器(
|
||||||
|
self, 上下文: 执行上下文, 计价: 调用计价 | None, *, 客户端: Any | None = None
|
||||||
|
) -> 模型执行器:
|
||||||
数据库 = self.要求数据库()
|
数据库 = self.要求数据库()
|
||||||
版本 = 配置版本管理(数据库).读取任务绑定(上下文.领取.任务ID)
|
版本 = 配置版本管理(数据库).读取任务绑定(上下文.领取.任务ID)
|
||||||
内容 = 版本.内容
|
内容 = 版本.内容
|
||||||
@ -221,7 +223,7 @@ class 应用装配:
|
|||||||
self.要求原文(),
|
self.要求原文(),
|
||||||
self.要求证据(),
|
self.要求证据(),
|
||||||
策略,
|
策略,
|
||||||
直接宿主(HTTP传输(提供方.地址, 凭据.来源, 凭据.位置, 提供方.协议)),
|
直接宿主(HTTP传输(提供方.地址, 凭据.来源, 凭据.位置, 提供方.协议, 客户端=客户端)),
|
||||||
实际计价,
|
实际计价,
|
||||||
固定配置=版本,
|
固定配置=版本,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
from collections.abc import AsyncIterable, AsyncIterator
|
from collections.abc import AsyncIterable, AsyncIterator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@ -39,12 +40,35 @@ async def 解码SSE(字节流: AsyncIterable[bytes]) -> AsyncIterator[dict]:
|
|||||||
raise 模型协议错误("SSE 在完整事件分隔符之前断开")
|
raise 模型协议错误("SSE 在完整事件分隔符之前断开")
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def 作用域HTTP客户端(
|
||||||
|
*,
|
||||||
|
连接池限制: httpx.Limits | None = None,
|
||||||
|
) -> AsyncIterator[httpx.AsyncClient]:
|
||||||
|
"""在当前事件循环作用域内管理长连接客户端生命周期;退出时确保完成 aclose 释放。"""
|
||||||
|
limits = 连接池限制 or httpx.Limits(
|
||||||
|
max_keepalive_connections=20,
|
||||||
|
max_connections=50,
|
||||||
|
keepalive_expiry=60.0,
|
||||||
|
)
|
||||||
|
客户端 = httpx.AsyncClient(
|
||||||
|
limits=limits,
|
||||||
|
follow_redirects=False,
|
||||||
|
trust_env=False,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
yield 客户端
|
||||||
|
finally:
|
||||||
|
await 客户端.aclose()
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class HTTP传输:
|
class HTTP传输:
|
||||||
地址: str
|
地址: str
|
||||||
凭据方式: str
|
凭据方式: str
|
||||||
凭据位置: str
|
凭据位置: str
|
||||||
协议: str
|
协议: str
|
||||||
|
客户端: httpx.AsyncClient | None = field(default=None, repr=False)
|
||||||
|
|
||||||
def 准备(self, 请求: dict, 总期限秒: float) -> 已准备HTTP请求:
|
def 准备(self, 请求: dict, 总期限秒: float) -> 已准备HTTP请求:
|
||||||
try:
|
try:
|
||||||
@ -68,13 +92,12 @@ class HTTP传输:
|
|||||||
头 = {
|
头 = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Accept-Encoding": "identity",
|
"Accept-Encoding": "identity",
|
||||||
"Connection": "close",
|
|
||||||
}
|
}
|
||||||
if self.协议 == "anthropic":
|
if self.协议 == "anthropic":
|
||||||
头.update({"x-api-key": 密钥, "anthropic-version": "2023-06-01"})
|
头.update({"x-api-key": 密钥, "anthropic-version": "2023-06-01"})
|
||||||
else:
|
else:
|
||||||
头["Authorization"] = "Bearer " + 密钥
|
头["Authorization"] = "Bearer " + 密钥
|
||||||
return 已准备HTTP请求(str(地址), tuple(头.items()), 数据, 总期限秒)
|
return 已准备HTTP请求(str(地址), tuple(头.items()), 数据, 总期限秒, 客户端=self.客户端)
|
||||||
|
|
||||||
async def 流(self, 请求: dict, 总期限秒: float) -> AsyncIterator[dict]:
|
async def 流(self, 请求: dict, 总期限秒: float) -> AsyncIterator[dict]:
|
||||||
async for 事件 in self.准备(请求, 总期限秒).流():
|
async for 事件 in self.准备(请求, 总期限秒).流():
|
||||||
@ -87,15 +110,26 @@ class 已准备HTTP请求:
|
|||||||
头: tuple[tuple[str, str], ...] = field(repr=False)
|
头: tuple[tuple[str, str], ...] = field(repr=False)
|
||||||
数据: bytes = field(repr=False)
|
数据: bytes = field(repr=False)
|
||||||
总期限秒: float
|
总期限秒: float
|
||||||
|
客户端: httpx.AsyncClient | None = field(default=None, repr=False)
|
||||||
|
|
||||||
async def 流(self) -> AsyncIterator[dict]:
|
async def 流(self) -> AsyncIterator[dict]:
|
||||||
|
# 作用域客户端由调用方在当前事件循环内创建并负责关闭;未注入或已关闭时退回一次性客户端。
|
||||||
|
作用域 = self.客户端
|
||||||
|
if 作用域 is not None and not 作用域.is_closed:
|
||||||
|
客户端, 需关闭 = 作用域, False
|
||||||
|
else:
|
||||||
|
客户端 = httpx.AsyncClient(
|
||||||
|
timeout=self.总期限秒, follow_redirects=False, trust_env=False
|
||||||
|
)
|
||||||
|
需关闭 = True
|
||||||
try:
|
try:
|
||||||
async with asyncio.timeout(self.总期限秒):
|
async with asyncio.timeout(self.总期限秒):
|
||||||
async with httpx.AsyncClient(
|
|
||||||
timeout=self.总期限秒, follow_redirects=False, trust_env=False
|
|
||||||
) as 客户端:
|
|
||||||
async with 客户端.stream(
|
async with 客户端.stream(
|
||||||
"POST", self.地址, content=self.数据, headers=dict(self.头)
|
"POST",
|
||||||
|
self.地址,
|
||||||
|
content=self.数据,
|
||||||
|
headers=dict(self.头),
|
||||||
|
timeout=self.总期限秒,
|
||||||
) as 响应:
|
) as 响应:
|
||||||
if 响应.is_error:
|
if 响应.is_error:
|
||||||
raise 模型协议错误(
|
raise 模型协议错误(
|
||||||
@ -107,3 +141,6 @@ class 已准备HTTP请求:
|
|||||||
raise 模型协议错误("模型调用超过总期限") from None
|
raise 模型协议错误("模型调用超过总期限") from None
|
||||||
except httpx.HTTPError:
|
except httpx.HTTPError:
|
||||||
raise 模型协议错误("模型传输中断,调用结果可能未知") from None
|
raise 模型协议错误("模型传输中断,调用结果可能未知") from None
|
||||||
|
finally:
|
||||||
|
if 需关闭:
|
||||||
|
await 客户端.aclose()
|
||||||
|
|||||||
@ -44,6 +44,7 @@ from muse.任务运行.接口 import (
|
|||||||
from muse.共享.调用身份 import 内容用途
|
from muse.共享.调用身份 import 内容用途
|
||||||
from muse.共享.错误 import Muse错误
|
from muse.共享.错误 import Muse错误
|
||||||
from muse.基础设施.数据库.连接 import 数据库工厂
|
from muse.基础设施.数据库.连接 import 数据库工厂
|
||||||
|
from muse.基础设施.模型.HTTP传输 import 作用域HTTP客户端
|
||||||
from muse.审校修订.接口 import 审校服务
|
from muse.审校修订.接口 import 审校服务
|
||||||
from muse.正文写作.接口 import (
|
from muse.正文写作.接口 import (
|
||||||
写作输出合同,
|
写作输出合同,
|
||||||
@ -202,9 +203,6 @@ def 登记生成正文(登记: 流程登记, 装配, 计价) -> None:
|
|||||||
def 受限探索(上下文: 执行上下文) -> 步骤结果:
|
def 受限探索(上下文: 执行上下文) -> 步骤结果:
|
||||||
任务 = 上下文.任务
|
任务 = 上下文.任务
|
||||||
工具 = 装配.要求只读工具(上下文.领取)
|
工具 = 装配.要求只读工具(上下文.领取)
|
||||||
清单: tuple[探索条目, ...] = ()
|
|
||||||
绑定集: dict[str, 来源绑定] = {}
|
|
||||||
历史: list[回合记录] = []
|
|
||||||
轮次上限 = int(
|
轮次上限 = int(
|
||||||
任务.冻结输入["冻结上下文"].get("stop_conditions", {}).get("探索轮数上限", 6)
|
任务.冻结输入["冻结上下文"].get("stop_conditions", {}).get("探索轮数上限", 6)
|
||||||
)
|
)
|
||||||
@ -213,6 +211,12 @@ def 登记生成正文(登记: 流程登记, 装配, 计价) -> None:
|
|||||||
for d in 工具.工具.values()
|
for d in 工具.工具.values()
|
||||||
)
|
)
|
||||||
探索提示, 探索模板哈希 = 受限探索模板()
|
探索提示, 探索模板哈希 = 受限探索模板()
|
||||||
|
|
||||||
|
async def _运行探索循环():
|
||||||
|
清单: tuple[探索条目, ...] = ()
|
||||||
|
绑定集: dict[str, 来源绑定] = {}
|
||||||
|
历史: list[回合记录] = []
|
||||||
|
async with 作用域HTTP客户端() as 客户端:
|
||||||
for _ in range(轮次上限):
|
for _ in range(轮次上限):
|
||||||
请求 = _写手请求(
|
请求 = _写手请求(
|
||||||
装配,
|
装配,
|
||||||
@ -230,9 +234,9 @@ def 登记生成正文(登记: 流程登记, 装配, 计价) -> None:
|
|||||||
工具=工具描述,
|
工具=工具描述,
|
||||||
历史=tuple(历史),
|
历史=tuple(历史),
|
||||||
)
|
)
|
||||||
执行器 = 装配.要求模型执行器(上下文, 计价)
|
执行器 = 装配.要求模型执行器(上下文, 计价, 客户端=客户端)
|
||||||
授权 = _原文授权(装配, 任务, 请求, 方式="persistent")
|
授权 = _原文授权(装配, 任务, 请求, 方式="persistent")
|
||||||
回合 = asyncio.run(执行器.执行回合(上下文, 请求, 阶段="探索", 原文授权ID=授权))
|
回合 = await 执行器.执行回合(上下文, 请求, 阶段="探索", 原文授权ID=授权)
|
||||||
if 回合.结果.状态 == "tool_calls":
|
if 回合.结果.状态 == "tool_calls":
|
||||||
回传: list[工具回传] = []
|
回传: list[工具回传] = []
|
||||||
for 调用 in 回合.结果.工具调用:
|
for 调用 in 回合.结果.工具调用:
|
||||||
@ -251,6 +255,9 @@ def 登记生成正文(登记: 流程登记, 装配, 计价) -> None:
|
|||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
raise 生成正文错误("探索轮数超出停止条件上限")
|
raise 生成正文错误("探索轮数超出停止条件上限")
|
||||||
|
return 清单, 绑定集
|
||||||
|
|
||||||
|
清单, 绑定集 = asyncio.run(_运行探索循环())
|
||||||
if not 清单:
|
if not 清单:
|
||||||
raise 生成正文错误("写手没有进行任何受限探索读取")
|
raise 生成正文错误("写手没有进行任何受限探索读取")
|
||||||
return 步骤结果(
|
return 步骤结果(
|
||||||
|
|||||||
120
tests/单元/test_模型长连接复用.py
Normal file
120
tests/单元/test_模型长连接复用.py
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
"""模型 HTTP 传输执行作用域长连接复用与生命周期机械验证。"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import threading
|
||||||
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
|
from socketserver import ThreadingMixIn
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from muse.基础设施.模型.HTTP传输 import HTTP传输, 作用域HTTP客户端
|
||||||
|
|
||||||
|
|
||||||
|
class _可复用HTTP服务(ThreadingMixIn, HTTPServer):
|
||||||
|
daemon_threads = True
|
||||||
|
|
||||||
|
def __init__(self, server_address, handler_cls):
|
||||||
|
super().__init__(server_address, handler_cls)
|
||||||
|
self.tcp_accept_count = 0
|
||||||
|
self.request_count = 0
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
|
||||||
|
def get_request(self):
|
||||||
|
sock, addr = super().get_request()
|
||||||
|
with self.lock:
|
||||||
|
self.tcp_accept_count += 1
|
||||||
|
return sock, addr
|
||||||
|
|
||||||
|
|
||||||
|
class _SSE处理器(BaseHTTPRequestHandler):
|
||||||
|
protocol_version = "HTTP/1.1"
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
length = int(self.headers.get("content-length", 0))
|
||||||
|
_ = self.rfile.read(length)
|
||||||
|
with self.server.lock:
|
||||||
|
self.server.request_count += 1
|
||||||
|
|
||||||
|
body = b'data: {"type": "response.output_text.delta", "delta": "ok"}\n\ndata: [DONE]\n\n'
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/event-stream")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
self.wfile.flush()
|
||||||
|
|
||||||
|
def log_message(self, format, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def 本地HTTP服务():
|
||||||
|
server = _可复用HTTP服务(("127.0.0.1", 0), _SSE处理器)
|
||||||
|
thread = threading.Thread(target=server.serve_forever)
|
||||||
|
thread.daemon = True
|
||||||
|
thread.start()
|
||||||
|
port = server.server_port
|
||||||
|
try:
|
||||||
|
yield f"http://127.0.0.1:{port}", server
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.网络
|
||||||
|
@pytest.mark.case_id("TC-HTTP-KEEP-ALIVE-REUSE")
|
||||||
|
def test_作用域HTTP客户端复用单条TCP连接(本地HTTP服务, tmp_path):
|
||||||
|
base_url, server = 本地HTTP服务
|
||||||
|
key_file = tmp_path / "mock-key.txt"
|
||||||
|
key_file.write_text("test-key")
|
||||||
|
|
||||||
|
async def _运行三次请求():
|
||||||
|
async with 作用域HTTP客户端() as 客户端:
|
||||||
|
传输 = HTTP传输(base_url, "受控存储", str(key_file), "responses", 客户端=客户端)
|
||||||
|
for _ in range(3):
|
||||||
|
事件 = []
|
||||||
|
async for 项 in 传输.流({"input": "hi"}, 总期限秒=5.0):
|
||||||
|
事件.append(项)
|
||||||
|
assert len(事件) == 1
|
||||||
|
assert 事件[0]["delta"] == "ok"
|
||||||
|
assert not 客户端.is_closed
|
||||||
|
assert 客户端.is_closed
|
||||||
|
|
||||||
|
asyncio.run(_运行三次请求())
|
||||||
|
|
||||||
|
# 3 次 HTTP 请求在同一作用域内共享同一个客户端,只建立 1 次真实 TCP 连接
|
||||||
|
assert server.request_count == 3
|
||||||
|
assert server.tcp_accept_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.网络
|
||||||
|
@pytest.mark.case_id("TC-HTTP-SHORT-CONNECTION-CONTRAST")
|
||||||
|
def test_未指定作用域时每次请求独立建连(本地HTTP服务, tmp_path):
|
||||||
|
base_url, server = 本地HTTP服务
|
||||||
|
key_file = tmp_path / "mock-key.txt"
|
||||||
|
key_file.write_text("test-key")
|
||||||
|
|
||||||
|
async def _运行三次独立请求():
|
||||||
|
# 无外部客户端,每次请求使用临时客户端
|
||||||
|
传输 = HTTP传输(base_url, "受控存储", str(key_file), "responses")
|
||||||
|
for _ in range(3):
|
||||||
|
事件 = []
|
||||||
|
async for 项 in 传输.流({"input": "hi"}, 总期限秒=5.0):
|
||||||
|
事件.append(项)
|
||||||
|
assert len(事件) == 1
|
||||||
|
|
||||||
|
asyncio.run(_运行三次独立请求())
|
||||||
|
|
||||||
|
assert server.request_count == 3
|
||||||
|
assert server.tcp_accept_count == 3
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.case_id("TC-HTTP-HEADER-NO-CONNECTION-CLOSE")
|
||||||
|
def test_请求头移除强制关闭并保留恒等编码(tmp_path):
|
||||||
|
key_file = tmp_path / "mock-key.txt"
|
||||||
|
key_file.write_text("test-key")
|
||||||
|
传输 = HTTP传输("http://127.0.0.1:8000", "受控存储", str(key_file), "responses")
|
||||||
|
准备 = 传输.准备({"input": "test"}, 总期限秒=10.0)
|
||||||
|
头字典 = dict(准备.头)
|
||||||
|
assert "Connection" not in 头字典 or 头字典["Connection"] != "close"
|
||||||
|
assert 头字典.get("Accept-Encoding") == "identity"
|
||||||
@ -35381,6 +35381,66 @@
|
|||||||
],
|
],
|
||||||
"markers": []
|
"markers": []
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"case_id": "TC-HTTP-HEADER-NO-CONNECTION-CLOSE",
|
||||||
|
"file": "tests/单元/test_模型长连接复用.py",
|
||||||
|
"symbol": "test_请求头移除强制关闭并保留恒等编码",
|
||||||
|
"parameter_ids": [],
|
||||||
|
"node_ids": [
|
||||||
|
"tests/单元/test_模型长连接复用.py::test_请求头移除强制关闭并保留恒等编码"
|
||||||
|
],
|
||||||
|
"fixtures": [
|
||||||
|
"request",
|
||||||
|
"tmp_path",
|
||||||
|
"tmp_path_factory",
|
||||||
|
"测试资源接缝",
|
||||||
|
"源码资源",
|
||||||
|
"离线防护"
|
||||||
|
],
|
||||||
|
"markers": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case_id": "TC-HTTP-KEEP-ALIVE-REUSE",
|
||||||
|
"file": "tests/单元/test_模型长连接复用.py",
|
||||||
|
"symbol": "test_作用域HTTP客户端复用单条TCP连接",
|
||||||
|
"parameter_ids": [],
|
||||||
|
"node_ids": [
|
||||||
|
"tests/单元/test_模型长连接复用.py::test_作用域HTTP客户端复用单条TCP连接"
|
||||||
|
],
|
||||||
|
"fixtures": [
|
||||||
|
"request",
|
||||||
|
"tmp_path",
|
||||||
|
"tmp_path_factory",
|
||||||
|
"本地HTTP服务",
|
||||||
|
"测试资源接缝",
|
||||||
|
"源码资源",
|
||||||
|
"离线防护"
|
||||||
|
],
|
||||||
|
"markers": [
|
||||||
|
"网络"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case_id": "TC-HTTP-SHORT-CONNECTION-CONTRAST",
|
||||||
|
"file": "tests/单元/test_模型长连接复用.py",
|
||||||
|
"symbol": "test_未指定作用域时每次请求独立建连",
|
||||||
|
"parameter_ids": [],
|
||||||
|
"node_ids": [
|
||||||
|
"tests/单元/test_模型长连接复用.py::test_未指定作用域时每次请求独立建连"
|
||||||
|
],
|
||||||
|
"fixtures": [
|
||||||
|
"request",
|
||||||
|
"tmp_path",
|
||||||
|
"tmp_path_factory",
|
||||||
|
"本地HTTP服务",
|
||||||
|
"测试资源接缝",
|
||||||
|
"源码资源",
|
||||||
|
"离线防护"
|
||||||
|
],
|
||||||
|
"markers": [
|
||||||
|
"网络"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"case_id": "TC-O08-SSE-001",
|
"case_id": "TC-O08-SSE-001",
|
||||||
"file": "tests/单元/test_任务事件收尾.py",
|
"file": "tests/单元/test_任务事件收尾.py",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user