我花了三天时间,对 Jev 的官方 API 端点打了差不多一万次请求。不是压测,也不是找漏洞,就是想搞清楚它背后到底是什么结构——因为它的文档里只写了怎么传参数、怎么拿结果,剩下的一个字都没提。Jev 是一个只开放接口的推理模型,不开源、不提供架构白皮书,甚至没有一页系统设计说明。可如果真要把 Jev 接进生产链路,你总得知道它撑不撑得住、限流怎么打、上下文存在哪里、并发上去之后会不会雪崩。API 文档不答的,黑盒调用会答。
这篇文章就是我这次完整推断过程的记录:怎么设计探测实验、怎么从响应头和时间分布里读信息、怎么用会话实验去确认存储层、最后怎么把这些线索拼成一个可用的架构轮廓。我不会把它写成"黑盒测试入门",也不是教你怎么攻击谁,而是一次真实的方法论复盘。读完你至少能获得一套可以迁移到任意黑盒服务上的行为分析方法,以及我踩过的那些坑。
1. 为什么不是看文档,而是用一万次请求给 Jev 画像
1.1 Jev 的文档里缺了哪些东西
Jev 的接入文档只有一个 API 端点、一个鉴权方式、几个请求参数示例。它不告诉你服务部署在哪里,不告诉你并发上限,不告诉你上下文窗口之外发生了什么,甚至连"服务端是否有状态"这种最基础的事情都没写。
这本来也正常,很多模型服务都不公开内部架构。但问题在于:你要在一个真实业务里接入它,就绕不开几个具体问题——调用超时应该设多少?重试策略怎么定?多轮对话要不要自己维护历史?限流大概什么时候会触发?如果并发从 10 突然涨到 50,它会不会直接 503 把请求全部拒掉?
文档不回答这些问题,只有行为能回答。
1.2 黑盒调用不是破解,是行为测量
"黑盒调用"听起来像黑客行为,实际上没有那么玄乎。它本质上就是一种行为测量:你只看输入和输出,不碰内部实现,通过有控制的实验推断系统的结构。
和它对应的是白盒分析,即拿到源码或内部文档直接看。但 Jev 没有开源,走白盒这条路从一开始就不存在。还有一条灰盒路线,比如去看 TLS 证书、DNS 记录、官方域名解析出来的 IP 段,这些属于外围侦察,能提供一部分线索,但撑不起架构推断。黑盒调用最大的优势是它对接口层面的行为看得最清楚。
我做了三层观测:请求层面的响应头和状态码、时序层面的时延和吞吐、内容层面的输出规律。这三层数据叠加在一起,能看的远比单次调用多得多。
1.3 为什么是一万次而不是一百次
单次调用能说明的问题太有限。一个请求返回 200,什么都证明不了;返回一次 429,也可能是系统打了个喷嚏。架构推断本质上是统计推断,你需要看的是一个分布,而不是一个点。
一百次请求能知道平均值大概在哪,但少到不足以支撑 p95/p99 百分位的判断,也不足以区分限流算法。等到一千次,响应头的变化规律开始出现可辨识的模式。做到一万次,数据量足够把时延分布切成多个峰来看,也足够触发绝大多数限流和过载保护策略。
实际上一万次并没有真的跑满那么久。我做的所有实验都是合法调用,带节流的,分摊到三天,平均每分钟也就打几十次请求。真正花时间的是设计实验和分析数据,不是写脚本。
2. 探测矩阵:把一万次调用拆成四个象限
2.1 四个实验维度和次数分配
盲目打一万次是浪费。我在动手之前先列了一张表,把所有可能影响系统行为的变量分成四个维度,每个维度分配 2500 次左右的调用。
| 维度 | 考察的内容 | 设计方式 |
|---|---|---|
| 负载形态 | 并发度、流式/非流式、请求体大小 | 从 1 并发到 64 并发做梯度扫描 |
| 输入特征 | 长短文本、多轮对话、角色设定、结构化内容 | 构造不同长度和类型的 prompt |
| 参数扰动 | temperature、max_tokens、seed、stop | 固定其他条件,只改动一个参数 |
| 故障触发 | 鉴权失败、超长输入、非法参数、高频请求 | 观察不同错误下的响应边界 |
这个分配不是平均主义。负载形态是重点,因为它直接关系架构中的队列、批处理和并发模型;输入特征的作用是确认上下文管理和状态存储;参数扰动用来探测缓存层;故障触发则是把限流、鉴权和参数校验的边界画出来。
2.2 一个能跑完一万次的异步脚本
写脚本的原则很简单:够异步、够容错、够留痕。我用的是 Python 的 httpx 异步客户端,因为它在并发场景下的表现比较稳定,而且能拿到完整的响应头。
import asyncio import time import json import httpx API_URL = "https://api.jev.example/v1/chat/completions" API_KEY = "$JEV_API_KEY" # 测试环境专用 key,不要真打生产 HEADERS = {"Authorization": f"Bearer {API_KEY}"} async def probe_once(client, payload: dict, tag: str) -> dict: start = time.perf_counter() result = { "tag": tag, "timestamp": start, "status": None, "ttft": None, "total": None, "output_len": 0, "x_request_id": None, "retry_after": None, } try: async with client.stream("POST", API_URL, json=payload, headers=HEADERS) as resp: result["status"] = resp.status_code result["x_request_id"] = resp.headers.get("x-request-id") result["retry_after"] = resp.headers.get("retry-after") first_chunk = True async for chunk in resp.aiter_text(): if first_chunk: result["ttft"] = time.perf_counter() - start first_chunk = False result["output_len"] += len(chunk) except Exception as exc: result["error"] = str(exc) result["total"] = time.perf_counter() - start return result async def run_batch(payload: dict, tag: str, concurrency: int = 8, rounds: int = 100): transport = httpx.AsyncHTTPTransport(retries=0) # 重试归零,否则会污染限流观测 async with httpx.AsyncClient(transport=transport, timeout=30) as client: sem = asyncio.Semaphore(concurrency) async def worker(): async with sem: return await probe_once(client, payload, tag) tasks = [worker() for _ in range(rounds)] return await asyncio.gather(*tasks, return_exceptions=True)这个脚本里我故意把重试设成了 0。原因很简单:如果客户端自己重试了,429 和 503 的原始特征就会被抹掉,你根本分不清到底是服务端拒了还是客户端自己多发了几次。所有原始请求都要原样记录,重试逻辑留给事后分析。
2.3 数据记录的字段选择
跑完一万次,原始数据如果不落库,等于白跑。我给每次请求记了下面这些字段:
- timestamp:精确到毫秒的事件时间,用于和系统时钟对表
- status_code:HTTP 状态码
- ttft:首字节或首 chunk 到达的延迟
- total:总耗时
- output_len:返回内容的长度
- x_request_id:用于追踪单个请求在服务端的身份
- retry_after:限流响应里带的字段
- tag:实验分组标识
- payload_hash:请求体的哈希,用于缓存实验的对账
落库用 Sqlite 就够。一万行的数据量,pandas 读进来做 groupby 分析非常轻松。分析阶段核心是无条件先分组,再看每个组里的分布特征,而不是看全量平均值——全量平均数是会把不同来源的时延混在一起的那种指标,非常误导人。
2.4 设计探测时容易踩的坑
第一个坑是并发设置不干净。如果不控制客户端的连接池大小,实际打到服务端的并发数会和你以为的不一致,时延数据就会有噪音。
第二个坑是忽略了冷启动的影响。服务端的容器在闲置一段时间后可能有冷缓存,你凌晨跑的第一批请求会比下午同一批慢很多。处理办法是固定时间段跑完对照组和实验组,不要在中间间隔太久。
第三个坑是"打一枪换一个地方"。有人喜欢今天测 10 个,明天测 10 个,数据完全没法比。一万次调用必须做成连续、可控的实验批次,前后条件保持一致。
第四个坑纯粹是伦理问题:不要对着生产核心链路去灌流量,也不要用别人的生产 key 去试。我这次都是用的测试端点和测试 key,打到一个还不在正式服务链路上的沙箱环境。黑盒推断讲究的是观察,不是破坏,不该碰的东西坚决不碰。
3. 响应头、状态码和限流算法:网关层最先露出的马脚
3.1 响应头组合变化的含义
响应头是黑盒分析里信息密度最高的部分。Jev 正常响应里有两个固定头值得注意:x-request-id和content-type。前者每次请求都会变,格式是 32 位 hex;后者在流式模式下是text/event-stream,非流式模式下是application/json。
x-request-id的存在本身就说明请求链路的前面至少有一层代理或者网关。如果只有后端应用而没有网关,这个头很难出现得如此统一。它给我提供了追踪同一个请求在多个实验里位置关系的基础。
更有意思的是,鉴权失败和正常响应的响应头集合不一样。鉴权失败时只有content-type和date,没有x-request-id。这说明鉴权是在网关层完成的,请求甚至没走到后面的业务服务就被拦下了。403/401 的返回体格式也和业务错误不同,业务错误返回的是模型服务风格的 JSON,鉴权错误返回的是更通用的包装层格式。
还有一点:server头没有暴露任何具体软件名,这在今天已经很常见,但它仍然是有用的信息——故意隐藏环境信息的服务,往往是因为前面套了不止一层代理。
3.2 TTFT 的多峰分布
我以为 TTFT 会是单峰分布,结果拿到了三峰。
- 第一个峰集中在 60–90ms:短 prompt、非流式、低并发下的响应
- 第二个峰在 240–280ms:流式请求、正常并发范围下的首 chunk 延迟
- 第三个峰在 900ms 以上:新会话首次请求,或者使用了较长上下文时会出现
多峰分布的含义是:请求链路并不是一条直线。第一类和第二类请求的路径不一样,典型的解释是流式和非流式走了不同处理管道;第三类则指向某种需要额外初始化的路径,比如首次构建会话状态,或者需要从存储里拉取上下文。
如果整个系统只有一层的单体服务,TTFT 最多受负载影响整体右移,不会出现三个相互清晰的峰。峰之间的空隙,通常意味着系统里有明确的组件边界。
我在之后分析会话 ID 的规律时,进一步确认了第三类峰和会话状态初始化有关。这个跨实验的交叉验证思路很关键:单看一个实验有无数种的解释,但多个实验指向同一个解释时,可信度会大幅上升。
3.3 错误码与限流指纹
HTTP 状态码看起来大同小异,但组合起来很有讲究。我在 Jev 上观察到的错误码体系是这样的:
- 401:鉴权失败,由网关层直接返回,无
x-request-id - 400:业务参数错误,有
x-request-id,错误体里有详细的参数名 - 429:限流触发,有
x-request-id,且带Retry-After头 - 503:服务过载或临时不可用,响应体格式与 400 一致,但没有
Retry-After
400 和 503 的响应体格式完全一致,说明它们来自同一个业务网关的路由层。503 不带Retry-After则说明过载的处理逻辑和限流并不在同一处。换句话说,网关对 429 有主动的限流算法控制,而对 503 只是被动地往上层抛连接失败。
这已经是很典型的分布式架构信号了:限流器在网关层,而过载保护更多依赖于后端实例的熔断和连接池管理,而不是统一决定。
3.4 三类限流算法的行为对照
为了确定限流类型,我做了高频连续调用:32 并发连续打,观察到 429 的分布特征如下:
- 前 40 个请求全部正常返回
- 第 41 到第 60 个之间出现零星 429,但很快又恢复 200
- 第 61 个开始连续 429,
x-ratelimit-remaining在从 40 递减到 0 之后就不再恢复 - 停止 20 秒再打,又能连续通过三十个左右
这个模式非常像令牌桶,而不是固定窗口或滑动窗口。
| 限流算法 | 行为特征 | 我的观察是否匹配 |
|---|---|---|
| 固定窗口 | 窗口边界突然全部恢复,窗口内稳定限死 | 不匹配,恢复是渐进的 |
| 滑动窗口 | 恢复更平滑,但边界不产生尖峰 | 不匹配,恢复有明确突跳 |
| 令牌桶 | 先突发通过一个桶容量,然后连续拒绝,随补充速率逐步恢复 | 匹配 |
这说明网关层至少有一个容量约 40 的令牌桶限流器,补充速率接近每秒 1.5 到 2 个 token。这个信息对客户端设计很重要:如果你的业务是短时爆发型,大批量请求会在前几十个成功之后突然全部 429,必须要设计成低于补充速率的长尾式提交,而不是一波流。
4. 会话状态与重复请求:存储层和缓存层的指纹
4.1 会话 ID 的设计语言
Jev 的接口允许你传一个session_id,不传也可以。这个小小的参数暴露了它和其他无状态推理服务的差异。
我创建了大量会话去观察 ID 的结构。Jev 的会话 ID 是 32 位 hex 字符串,但仔细看并不是纯 UUIDv4:中间段有可感知的递增规律。UUIDv4 的随机性是完全均匀的,不会在连续创建时出现递增段。递增段的存在说明会话 ID 至少有一部分是由某种有状态的分配器生成的,很可能是数据库自增键或者分布式序号发生器转 hex 而来。
这个判断调门不能定得太死,但至少可以把"纯随机 UUID"从假设里排除掉。后面我用行为实验确认了服务端确实保存了会话状态。
4.2 服务端状态与客户端状态的判别
怎么判断状态在服务端还是客户端?我做了这样一个实验。
第一次请求:带一个新建的session_id,发送消息 A,内容是"记住一个词:苹果手机壳是蓝色渐变款",返回正常。 第二次请求:不传历史消息,只传同一个session_id和消息 B,内容是"我上次说的那个手机壳是什么颜色?"
如果服务端保存了状态,它会基于消息 A 回答"蓝色渐变款"。如果服务端不保存状态,它就只能看到消息 B,然后回答"我不知道"。
Jev 的回答是正确的。重复几次,结论稳定。这说明它的接口虽然长得像无状态 API,但实际服务端有一个状态存储层,session_id就是存储层的键。
再往深一层,随着对话轮次增加,我发现超过 20 轮左右,模型开始"忘掉"更早期的细节,24 轮之后几乎无法引用最早的消息。这个遗忘行为不是均匀衰减,而是有明确边界的截断,更像是一个固定容量的队列或者 LRU 缓存。它不太像模型注意力窗口的自然限制,更像服务于会话的存储组件设定了保留上限。
4.3 语义缓存存在的实证
缓存层的存在,我用一个重复请求实验就能验证。
我固定同一个 prompt、同一个 temperature,连续请求同一个会话,记录 TTFT。第一发 890ms,第二发 620ms,第三发 598ms。第二发掉下去接近 270ms,这个量级不可能来自网络波动,只能说明后端存在针对这个请求的缓存。
接下来我把 temperature 从 0.3 改成 0.7,同样的 prompt 重新打,TTFT 又回到了 850ms 以上。如果缓存只对 prompt 做 key,那么 temperature 改了不应该失效;现在失效了,说明缓存 key 至少包含了 prompt 和采样参数。这个结果非常关键,它说明缓存放的不是"输入文本到输出文本"的简单 KV,而是带着推理配置一起参与计算的语义缓存。
这个实验带来的实际建议是:如果你在生产环境要追求低延迟,尽量让请求的采样参数固定。每次改 temperature 都会打穿缓存,等于白白丢掉了优化机会。
4.4 上下文截断方式透露的存储结构
我再测长上下文的边界行为:不断给同一个会话追加长文本,直到触发异常。
Jev 的表现不是直接报 400,而是把过长的输入截断到某个上限然后继续处理。截断的行为很规律:它丢弃了最老的那部分消息,保留了最近的新消息。这正好对上了多轮实验里"20 轮之后旧信息消失"的规律——状态存储服务在写入新消息时会淘汰最旧的消息。
把这个行为模式翻译成架构语言,就是:状态层是一个有容量上限的队列式存储,入队新消息、淘汰队首旧消息,保留的窗口大约是 20 轮或对应的 token 数量。这类实现通常背后是 Redis 的列表结构或者等价的环形缓冲,而不是关系型数据库。
多轮上下文的服务端存储,是 Jev 一个很明确的架构标签。很多模型服务为了提高水平扩展能力都选择了完全无状态设计,把上下文管理扔给客户端;Jev 选择了有状态设计,说明它的会话服务很可能是独立部署的存储组件,而不是和推理引擎混在一起。
5. token 流速与随机性:推理引擎的微观行为
5.1 输出速率随并发的台阶变化
推理引擎的很多特征会直接反映在输出速率和并发的关系上。我用流式请求测了一组输出速度数据,单位是 tokens/s:
- 1 并发:45 tokens/s
- 8 并发:28 tokens/s
- 16 并发:18 tokens/s
- 32 并发:11 tokens/s
从 1 到 8 是平滑下降,从 8 到 16 出现了明显的台阶,从 16 到 32 又是一段平缓下降。台阶式的下降轨迹说明推理引擎在做批处理调度:它在并发到一定阈值后切换了策略,类似从单请求独占计算切到了动态批处理,多个请求共享一个 batch 但单位吞吐被摊薄。
单机单体程序在并发上升时通常是连续衰减或者直接报错,很难出现这种规律性的台阶。结合前面 503 的现象,一个合理的推测是:推理层是一个由多实例组成的资源池,网关按负载把请求分发到不同实例,每个实例内部再做连续批处理。
5.2 随机性实验(temperature、流式)
我还做了随机性测试。同一段 prompt、同一 temperature、完全相同的参数,连续请求多次。如果输出完全一致,说明推理采样路径高度确定;如果输出有微小变化,说明采样环节引入了随机性。
实际情况是:temperature=0 时输出约 92% 的字符完全一致,但有 8% 请求会有一个 token 级别的小差异。也就是说 Jev 在 temperature=0 时并不能保证逐字稳定,这在生产上有一个直接影响:不要拿它做需要严格一致响应的场景,或者必须在应用层做结果校验。
流式模式下我留意了各 chunk 的到达间隔。Jev 的流式输出 chunk 大小比较均匀,大约 20ms 一个 chunk,每个 chunk 里包含若干 token。这说明它的 tokenizer 和生成器在同一个进程环境内完成,较少出现因为中间代理缓冲导致的 burst 式吐 chunk 行为。
5.3 安全过滤层的独立性问题
为了确认是否存在独立的安全过滤层,我用明显违规的内容做了一次受控实验(只在沙箱测试端点测,内容本身没有扩散)。
结果很有代表性:当 prompt 命中违规类型时,响应仍然是 HTTP 200,但输出是一整段固定模板,内容是拒绝生成的通用回复。关键点在于它是"一次性吐出整个拒绝模板",而不是流式地逐 token 逐渐输出。如果拒绝逻辑是在推理引擎内的采样阶段完成的,通常会看到类似普通生成的逐 token 流出,顶多在中途拐弯;现在是一个 chunk 直接给完,说明是某个中间服务检查完 prompt 之后,直接把预设的替换文本作为响应返回,根本没有人推理引擎。
这明显是一个独立安全中间件的行为:它拦截请求、做内容审核,然后直接回复模板,绕过了昂贵的推理计算。这个组件位于网关之后、推理引擎之前,位置非常清晰。
6. 拼图闭合:Jev 的架构轮廓与接入注意事项
6.1 文字版架构图
把前面各章的线索汇总,我得到了一份这样的架构轮廓:
客户端 └── 边缘网关层 ├─ 鉴权校验(无状态,请求未通过时不留下后续链路痕迹) ├─ 令牌桶限流(容量约 40,补充速率约 1.5-2/s) ├─ 路由分发(生成 x-request-id) ├─ 安全审核中间件(命中后直接返回拒绝模板) └─ 会话状态服务 ├─ 服务端保存上下文 ├─ 保留约 20 轮后淘汰旧消息 └─ 独立于推理引擎存储 └── 推理引擎池 ├─ 多实例水平扩展 ├─ 每个实例做动态批处理 └─ 流式和非流式走不同路径这样一个结构,在业内并不算激进,但它和"把模型封装成一个 HTTP 后端"的简单实现有明显区别:它有多层中间件,有状态存储,有独立审核服务,有批处理调度。对 Jev 的服务方来说,这套架构是认真的规模化架构,不是临时 demo。
6.2 证据到结论的映射表
我把每个最终结论和支撑它的证据绑定在一起,这样后续如果行为变化,可以快速判断是哪一层出了问题。
| 架构层 | 结论 | 关键证据 |
|---|---|---|
| 网关 | 存在统一入口 | 所有正常请求都有 x-request-id |
| 鉴权 | 在网关完成 | 鉴权失败不出现 x-request-id,响应头集合不同 |
| 限流 | 令牌桶,容量约 40 | 429 恢复模式呈渐进式,有 Retry-After |
| 状态 | 服务端保存约 20 轮 | 传 session_id 不带历史也能引用旧内容 |
| 缓存 | 带采样参数的语义缓存 | 同参请求第二发明显变快,改 temperature 后失效 |
| 安全 | 独立审核中间件 | 违规内容一次性返回固定模板,不走推理流式 |
| 推理 | 多实例动态批处理 | 输出速率随并发呈台阶下降 |
6.3 对接入方的三个启示
第一,超时设置不能只看总时延。我测到的 p95 总时延约 2.8 秒,但在触发限流边缘时单次请求会拖到 8 秒以上。客户端超时如果卡死在 5 秒,会在限流恢复阶段造成大量误杀。更合理的策略是区分"首字节超时"和"总超时":首字节给 3 秒,总超时给 30 秒,流式模式下重置总超时计时。
第二,重试必须做指数退避加抖动。发现 429 后停止重试 3 到 5 秒,比立刻重试更有效。因为令牌桶的补充速率很慢,立即重试只会把剩余的桶容量也耗光,导致更长的整体停机。
第三,如果业务是多轮对话,不要再在客户端维护完整历史并每次回传。Jev 的服务端已经帮你管了最近 20 轮。你如果再客户端拼一遍历史,反而会把上下文窗口撑爆,触发它那种最快淘汰策略。正确的做法是只传session_id和当前用户消息,让状态服务接管历史。
7. 黑盒推断的边界:置信度分级与后续验证
7.1 可信度高的结论
黑盒推断给出的不是清单式的"事实",而是有置信度差别的假设。我习惯把结论按可信度分成三档,避免自己把猜测当成论证写进技术报告。
可信度高的一档,全部建立在可重复的行为观测上:限流是令牌桶、缓存受采样参数影响、服务端保存会话状态、存在独立审核中间件、TTFT 存在多峰分布。这些结论每次实验都能复现,不依赖任何假设,几乎不存在别的合理解释。
7.2 只能算合理猜测的部分
第二档是可信度中等但仍算合理猜测的:推理引擎是多实例、背后是动态批处理调度、会话状态存在 Redis 类组件中。这些结论是我通过输出速率台阶和上下文截断模式推测出来的,方向可信,但精确的技术选型仍然无法证明。黑盒分析的最大限制就在这里——你只能看到行为特征,看不到代码和配置。
第三档则完全是猜测:模型的具体参数量、硬件型号、网络拓扑、底层框架版本。这些信息黑盒怎么测都测不出来。如果有人看完文章问"Jev 大概有多少参数",我的回答只能是不确定。
7.3 合法合规的后续验证手段
黑盒推断不应该停在猜测。我验证推断的通道是公开域名解析、官方 API 文档更新、证书透明度日志这些合法合规的外围信息。比如观察 API 域名解析出的 IP 段是否多个,可以部分验证多实例的推断;看云厂商 prefix 信息能知道它部署在哪个区域;如果官方后续开放了状态管理文档,我可以直接比对会话 ID 的格式和 20 轮保留阈值。
这些验证手段不会去越权、抓包别人的流量、破解密钥或做任何超出正常用户使用边界的事。黑盒调用的意义是评估和认知,不是渗透和攻击。对一个你打算长期依赖的外部服务做系统性行为观察,本来就是工程上该做的功课。
一万次调用最后压缩下来,也就十几页记录和一张架构草图。但相比只看文档的状态,我对 Jev 的把握完全不是一个量级:知道它有限流就可以设计重试,知道它有服务端状态就可以简化客户端,知道它有安全中间件就知道内容审核在链路里的位置。这种"把黑盒看作一个测量对象"的方法,值得每个做 API 集成的工程师都练一遍。