
可观测性AI 评测LLMOpsAI 应用人工智能【免费下载链接】phoenixAI Observability Evaluation项目地址https://gitcode.com/gh_mirrors/phoenix13/phoenix点击查看免费下载本篇技术指南讲解如何在 PhoenixAI Observability Evaluation 平台仓库中为“会向 LLM 提供商发出 OpenInference Span 的代码”编写高质量测试。文章以 .agents/skills/phoenix-server/references/llm-trace-tests.md 为骨架结合仓库内 VCR 封装、fixture 体系与OpenInferenceModelWrapper源码实现展开读完你将掌握一套可复制的“录制-回放 全量属性断言”测试模式以及这些模式背后非显而易见的坑。针对 LLM 追踪代码的测试存在两个截然不同的关注点任何一个都不简单回放Replay——把 LLM 的 HTTP 交换固定下来让测试变得确定deterministic且完全离线断言Assertion——验证代码是否正确发射了 OpenInference Span 属性。如果只凭直觉去写这两件事都会踩进隐藏的坑里。下面分别展开。一、VCR 磁带Cassette工作流让 LLM 调用离线且确定Phoenix 仓库在 tests/unit/vcr.py 中提供了一个CustomVCR封装继承自vcr.VCRrecord_mode固定为once测试中通过custom_vcrfixture 使用。最基本的用法是with custom_vcr.use_cassette(): response await wrapped_model.request(...)从源码看tests/unit/vcr.pyCustomVCR在构造时默认启用了几个对 LLM 测试至关重要的行为record_modeonce没有磁带就录制有磁带就只回放decode_compressed_responseTrue自动解压响应保证磁带里存的是可读文本before_record_requestremove_request_headers与before_record_responseremove_response_headers录制时清空请求/响应头避免把Authorization等敏感信息写进磁带的 YAML 文件详见 tests/unit/vcr.pyignore_hosts[test]测试中经由 httpx ASGI transport 访问的虚拟 hosttest不会走网络录制。磁带路径由「测试模块名 测试函数名」推导这是本模式中最容易被忽视的约定。CustomVCR.use_cassette()会从 pytest 的FixtureRequest中取出测试类名、模块名与测试函数名自动拼出磁带路径file_name_parts.append(test_cls.__name__) # 若有测试类 file_name_parts.append(_remove_parameters(test_name)) # 去掉参数化后缀 [param] path test_file_path.parent / cassettes / module_name / f{..join(file_name_parts)}.yaml也就是说对于一个位于tests/unit/server/agents/pydantic_ai/test_openinference_model_wrapper.py的测试函数磁带会被写到同目录下cassettes/module名/ClassName.TestName.yaml模块级测试则没有类名前缀。因此重命名测试函数或移动测试文件会“孤儿化”原有磁带——要么把磁带一起移动要么删除磁带重新录制。录制 vs 回放两种运行模式的差异首次运行无磁带once模式会真实命中 LLM 提供商完成录制此时环境变量里必须有真实的 API key后续运行已有磁带只回放磁带不再发任何真实请求。但注意SDK 构造客户端时仍然需要“某个” API key——即使 VCR 拦截了 HTTP 调用key 的读取发生在构造阶段。因此必须始终接入 API-key fixture才能让回放模式在没有真实凭据的情况下也能跑通。tests/unit/conftest.py 已经提供了openai_api_key和anthropic_api_key两个 fixture它们把环境变量 monkeypatch 成假值sk-0123456789pytest.fixture def wrapped_model( tracer_provider: TracerProvider, anthropic_api_key: str, ) - OpenInferenceModelWrapper: return OpenInferenceModelWrapper( AnthropicModel(MODEL_NAME, providerAnthropicProvider()), tracer_providertracer_provider, )注意这里的关键点原文档 Gotchas 中明确强调AnthropicModel(providerAnthropicProvider())在构造时就会读取ANTHROPIC_API_KEY所以 API-key fixture 必须接在构造模型的 fixture上而不是只接在单个测试函数上。确定性Determinism不要断言自由文本LLM 的自由文本输出不可能精确断言。两种策略依赖磁带 只断言结构/角色不断言输出文本——但一旦重新录制就会变脆推荐做法让模型“复述一句精确的话”配合temperature0.0然后做全等断言。这样即使重新录制也能复现expected_output The capital of France is Paris. messages [ModelRequest(parts[ SystemPromptPart(contentfReply with exactly the following sentence and nothing else: {expected_output}), UserPromptPart(contentWhat is the capital of France?), ])] settings ModelSettings(temperature0.0, max_tokens32) ... assert response_text expected_output这个模式在仓库的实测用例中得到了完整印证test_request_emits_llm_span_for_text_response正是用 “Reply with exactly the following sentence and nothing else: …” 提示词加ModelSettings(temperature0.0, max_tokens32)来钉住输出见 tests/unit/server/agents/pydantic_ai/test_openinference_model_wrapper.py。请求变更时必须重新录制当你修改了提示词或出站请求的任何字段磁带上记录的请求体就不再匹配VCR 会直接拒绝该调用。永远删除磁带并对着真实提供商重新录制——绝不要手改 YAML。手改看起来很有诱惑力尤其是流式磁带你可能想把文本拆到多个content_block_delta事件里但代价是磁带会悄悄偏离提供商真实返回的任何响应形态录制就失去了意义提供商未来的 schema 变更会在生产环境给你惊喜而在测试里却照样通过极易引入细微的不一致token 数、ID、finish reason掩盖真实 bug。如果“复述精确句子”的提示词已经把输出文本钉死重新录制就是可复现的——确定性来自这个机制而不是来自编辑磁带。二、Span 属性断言全量、类型安全、防漂移回放搞定之后真正的验证重点在于 Span 属性。原文档总结了五条可执行的断言模式每一条都直指一类真实发生过的问题。1. 用popassert not attributes全量断言不要只抽查几个 key——那是让 bug 溜走的捷径例如 wrapper 意外发射了敏感字段。正确姿势是把每个期望属性pop出来逐一断言最后断言字典为空attributes dict(span.attributes or {}) assert attributes.pop(OPENINFERENCE_SPAN_KIND) LLM assert attributes.pop(LLM_PROVIDER) PROVIDER_ANTHROPIC # ... pop every attribute you expect ... assert not attributesassert not attributes这一行能抓住所有意外出现的属性这才是全量断言的精髓。2. 使用 OpenInference 语义约定常量而非字符串字面量from openinference.semconv.trace import ( MessageAttributes, SpanAttributes, ToolAttributes, ToolCallAttributes, ) LLM_INPUT_MESSAGES SpanAttributes.LLM_INPUT_MESSAGES MESSAGE_ROLE MessageAttributes.MESSAGE_ROLE MESSAGE_CONTENT MessageAttributes.MESSAGE_CONTENT MESSAGE_TOOL_CALLS MessageAttributes.MESSAGE_TOOL_CALLS MESSAGE_TOOL_CALL_ID MessageAttributes.MESSAGE_TOOL_CALL_ID TOOL_JSON_SCHEMA ToolAttributes.TOOL_JSON_SCHEMA TOOL_CALL_ID ToolCallAttributes.TOOL_CALL_ID TOOL_CALL_FUNCTION_NAME ToolCallAttributes.TOOL_CALL_FUNCTION_NAME TOOL_CALL_FUNCTION_ARGUMENTS_JSON ToolCallAttributes.TOOL_CALL_FUNCTION_ARGUMENTS_JSON assert attributes.pop(f{LLM_INPUT_MESSAGES}.0.{MESSAGE_ROLE}) user assert attributes.pop(f{LLM_INPUT_MESSAGES}.0.{MESSAGE_CONTENT}) prompt字符串字面量比如message.role能工作但规范一旦移动就会静默失败常量则天然防拼写错误。仓库的参考测试把这一整套常量别名块集中放在文件底部见 tests/unit/server/agents/pydantic_ai/test_openinference_model_wrapper.py。3. 用assert isinstance(...)而不是cast(...)OTel 属性值的类型是AttributeValue一个包含str、int、序列等的联合类型。cast(str, ...)只是对类型检查器撒谎isinstance才是真正的运行时检查inv_params attributes.pop(LLM_INVOCATION_PARAMETERS) assert isinstance(inv_params, str) assert json.loads(inv_params) dict(settings)配合海象运算符walrus operator可以同时完成类型收窄type-narrow和取值assert isinstance(prompt_tokens : attributes.pop(LLM_TOKEN_COUNT_PROMPT), int) assert isinstance(completion_tokens : attributes.pop(LLM_TOKEN_COUNT_COMPLETION), int) assert attributes.pop(LLM_TOKEN_COUNT_TOTAL) prompt_tokens completion_tokens4. 穷尽式校验input.value/output.value的 JSON不要只写assert messages in parsed_input。要断言完整的 key 集合以及每个确定性的字段parsed_input json.loads(input_value) assert set(parsed_input) {messages, model_settings, model_request_parameters} assert len(parsed_input[messages]) 1 assert parsed_input[messages][0][kind] request assert parsed_input[model_settings] dict(settings)set(parsed_input) {...}同样能抓住多余字段——和assert not attributes是同一个思想。在参考测试中这一模式被贯彻到了极致不仅校验了messages[0][kind]、每个 part 的part_kind与content连model_request_parameters里的function_tools/native_tools/output_tools/allow_text_output都逐一断言见 tests/unit/server/agents/pydantic_ai/test_openinference_model_wrapper.py。5. 流式断言事件累积与最终文本一致如果通过流式事件产出部分文本就在循环里累积并检查它等于最终拼接的完整文本event_text_chunks: list[str] [] async for event in stream: if isinstance(event, PartStartEvent) and isinstance(event.part, TextPart): event_text_chunks.append(event.part.content) elif isinstance(event, PartDeltaEvent) and isinstance(event.delta, TextPartDelta): event_text_chunks.append(event.delta.content_delta) final_response stream.get() streamed_text .join(p.content for p in final_response.parts if isinstance(p, TextPart)) assert .join(event_text_chunks) streamed_text expected_output这里有一个原文档特别强调的细节手工构造磁带让文本跨多个 delta 事件下发否则“多 delta 累积”的逻辑根本不会被测试到。仓库参考测试test_request_stream_emits_llm_span完整演示了这一模式tests/unit/server/agents/pydantic_ai/test_openinference_model_wrapper.py。三、结合源码理解 Span 从何而来为了让属性断言写得对有必要理解被测对象到底发射什么。仓库中的OpenInferenceModelWrappersrc/phoenix/server/agents/pydantic_ai/openinference_model_wrapper.py是 pydantic-ai 的WrapperModel子类其_span上下文管理器揭示了 Span 属性的完整来源Span 名self.model_name即claude-haiku-4-5这类模型名见参考测试中的MODEL_NAME常量Span kind / LLM 元数据通过openinference.instrumentation的get_span_kind_attributes(llm)与get_llm_attributes(provider..., system..., model_name..., invocation_parameters...)生成输入get_input_attributes(input_value, mime_typeJSON)其中input_value是{messages: ..., model_settings: ..., model_request_parameters: ...}的原始对象——这正是断言时set(parsed_input) {...}三个 key 的来源输出响应返回后通过set_response闭包调用get_llm_attributes(output_messages[...], token_count...)与get_output_attributes(response)补齐输出侧属性token 计数_to_oi_token_count把RequestUsage映射为{prompt: ..., completion: ..., total: ...}并且只有当input_audio_tokens/cache_read_tokens/cache_write_tokens非零时才写入prompt_details——这正是下面 Gotcha 中“条件属性”问题的根源。理解了这个实现就能解释断言里的很多细节例如为什么INPUT_MIME_TYPE断言 JSON、为什么LLM_TOKEN_COUNT_TOTAL必须等于 prompt 与 completion 之和实现里total input output、为什么工具调用会被展平为MESSAGE_TOOL_CALLS.0.TOOL_CALL_FUNCTION_NAME这样的层级 key。四、Gotchas六个最容易踩的坑原文档最后集中列出了六个实战中反复出现的坑这里逐条展开条件属性Conditional attributes。wrapper 只在值非零时才设置cache_read/cache_writetoken 属性如果磁带上这些值为零对应 key根本不会出现。此时不要写防御性的attributes.pop(key, None)“以防万一”——它会掩盖真实的回归。要么在磁带里把值驱动到非零并正常断言要么接受这些 key 不出现。SDK 在哪里读 API key。如前面所述AnthropicModel(providerAnthropicProvider())在构造时读ANTHROPIC_API_KEY。API-key fixture 要接进构造模型的 fixture而不是只接在个别测试里。record_modeonce不会自动更新。一旦磁带存在请求若发生变化VCR 会以 “request not found” 错误失败——它不会悄悄重新录制。必须删除磁带再重录。常量块的位置。如果很多测试共享一大段SpanAttributes.X ...别名把它推到测试文件底部。测试/fixture 体内的名字在调用时才解析所以测试顺序无关紧要——这就是参考测试把常量块放在文件末尾L751 之后的原因。不要在 pydantic_ai 类型上构造测试辅助函数。ModelSettings(...)和ModelRequestParameters(...)都是直接构造函数不需要_settings(...)/_empty_request_parameters(...)之类的包装辅助函数直接在测试里内联构造即可。不要用sleep等待守护进程、不要碰启动捷径来自同目录 .agents/skills/phoenix-server/references/test-patterns.md 的延伸约束LLM 追踪测试同样遵循仓库统一的测试纪律——守护进程用控制器模式驱动app 启动捷径按需用real_*marker 退出。五、完整参考用例覆盖上述全部模式的工作示例位于 tests/unit/server/agents/pydantic_ai/test_openinference_model_wrapper.py。该文件演示了文本响应test_request_emits_llm_span_for_text_response复述提示词 temperature0.0钉死输出全量断言 span kind、provider、system、model name、invocation parameters、输入/输出消息、token 计数、input.value/output.value的完整 JSON 结构最后assert not attributes工具调用响应test_request_emits_llm_span_for_tool_call_response断言LLM_TOOLS.0.*的工具名/描述/JSON schema以及输出消息中MESSAGE_TOOL_CALLS的 id、函数名、参数 JSON原生工具调用test_request_emits_llm_span_for_native_tool_call_response通过TestModel完全离线地验证NativeToolCallPart的展平结果流式响应test_request_stream_emits_llm_span事件累积与最终文本的一致性断言多轮工具历史test_request_emits_tool_return_message_in_history验证上一轮的工具返回会以tool角色的输入消息出现在下一个 LLM Span 上异常路径test_request_raises_expected_exception_events不依赖网络、用raising_modelfixture 验证StatusCode.ERROR、exception事件属性与OUTPUT_VALUE缺席。运行这些测试以及本指南覆盖的所有模式使用仓库标准命令即可uv run pytest tests/unit/server/agents/pydantic_ai/test_openinference_model_wrapper.py -n autopytest 配置见 pytest-quiet.iniasyncio_mode auto允许async def test_*直接编写--import-modeimportlib保证测试模块以唯一名称导入。结语LLM 追踪测试的难点不在“写断言”而在“让回放可信、让断言无盲区”。用CustomVCR的once模式 API-key fixture 解决离线与确定性问题用“复述精确句子 temperature0”把输出钉死在断言侧坚持pop后assert not attributes的全量校验、语义约定常量、isinstance运行时检查与input.value/output.value的穷尽式 JSON 断言就能让每个 LLM Span 的发射行为被精确锁定同时把回归风险降到最低。赞分享可观测性AI 评测LLMOpsAI 应用人工智能【免费下载链接】phoenixAI Observability Evaluation项目地址https://gitcode.com/gh_mirrors/phoenix13/phoenix点击查看免费下载相关推荐PDF补丁丁免费开源的PDF工具箱3步为PDF批量生成书签并解除复制限制PDF补丁丁免费开源的PDF工具箱3步为PDF批量生成书签并解除复制限制 给一本300页的技术手册补书签阅读器只能逐页点击添加想引用文档里的段落却弹出可观测性AI 评测LLMOpsAI 应用人工智能视频无损剪辑革命LosslessCut如何让专业级视频处理变得简单高效视频无损剪辑革命LosslessCut如何让专业级视频处理变得简单高效 在当今数字化内容创作时代视频处理已成为日常需求但传统剪辑软件的复杂操作和漫长的渲染可观测性AI 评测LLMOpsAI 应用人工智能Cursor试用限制破解终极指南机器码重置工具一招刷新设备指纹Cursor试用限制破解终极指南机器码重置工具一招刷新设备指纹 Cursor试用限制这道坎几乎每个重度用户都踩过正写着代码屏幕突然弹出一句 Too ma可观测性AI 评测LLMOpsAI 应用人工智能上一篇【限时获取】ThingsPanel物联网平台v1.1.8深度解析安全认证与设备管理全面升级下一篇DGL 图基础入门从图论定义到 DGLGraph 实操Graphs 101创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考