Skip to content

Commit 38fac75

Browse files
committed
feat(evaluation): 完成离线模型与 Trace 回放验收链路
1 parent 2e5eb6c commit 38fac75

56 files changed

Lines changed: 7308 additions & 406 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# 设计边界
2+
3+
本示例把运行环境与替代组件分开描述。`offline``real``trace` 是三种 Pipeline 运行模式。`offline` 仍使用 SDK 的 `LlmAgent`、Runner 和独立 Session,只把 Agent 内部模型替换成 `DeterministicFakeModel`,并用确定性 Candidate Provider 代替真实优化器。它用于验证 Prompt 改变是否经过真实 Agent 编排影响输出。
4+
5+
`real` 使用真实业务模型生成回复,并由 `AgentOptimizer` 调用真实反思模型产生 Prompt 候选;只有 Gate 接受、源 Prompt 哈希未漂移且显式启用写回时,才允许更新源文件。
6+
7+
`trace` 直接评测预录制的 `actual_conversation`,不再次运行 Agent、Model 或 Candidate Provider。它适合复现工具轨迹和生产故障,但只能证明候选版本与轨迹的关联,不能证明 Prompt 导致了该轨迹。因此 Trace 即使获得 ACCEPT,也固定跳过源 Prompt 写回。
8+
9+
确定性 metric 负责精确匹配等硬规则。LLM Judge 若需要,应作为带 rubric 的评测指标显式配置;本示例不提供容易混淆职责的 `use_fake_judge` 开关。

examples/optimization/eval_optimize_loop/README.md

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1-
# Evaluation + Optimization Loop — Stage 5
1+
# Evaluation + Optimization Loop — Stage 6
2+
3+
## 三种运行模式
4+
5+
- `offline`:使用真实 SDK `LlmAgent``Runner`,Agent 内部注入 `DeterministicFakeModel`,候选由确定性 Candidate Provider 生成。无需 API Key。
6+
- `trace`:使用 SDK 原生 `eval_mode="trace"` 回放 baseline/candidate 的 train 与 validation 轨迹,不运行 Agent、Model 或 Candidate Provider。即使 Gate 接受,也不会写回源 Prompt。
7+
- `real`:业务 Agent 和反思优化模型均使用真实模型,由显式入口 `run_real_integration.py --run-real` 启用。
8+
9+
Fake Model 是 `offline` 模式中的模型实现,不是独立运行模式;Fake Candidate Provider 只替代候选生成,不替代业务 Agent。精确匹配等硬规则由 `optimizer.json` 的确定性 metric 执行。需要语义判断时,应显式配置带 rubric 的 LLM Judge,而不是使用布尔 Fake Judge 开关。详细边界见 [DESIGN.md](DESIGN.md)
210

311
This example provides an auditable evaluation and prompt-optimization loop.
412
Stages 1–3 prepare an isolated prompt workspace, run baseline and candidate
@@ -8,17 +16,25 @@ common Candidate Provider boundary, an `AgentOptimizer` adapter, and guarded
816
source-prompt writeback. Stage 5 publishes a complete JSON/Markdown report and
917
artifact index, or preserves a standalone failure report when a run fails.
1018

11-
The deterministic fake mode still runs without a model, API key, judge, or
12-
optimizer. Its built-in scenarios produce ACCEPT for `improve` and REJECT for
19+
The deterministic offline mode runs through the SDK Agent without an API key,
20+
judge, or real optimizer. Its built-in scenarios produce ACCEPT for `improve` and REJECT for
1321
both `no_improvement` and `overfit`:
1422

1523
```bash
1624
python examples/optimization/eval_optimize_loop/run_pipeline.py \
17-
--run-id local_stage5 \
25+
--run-id local_stage6 \
1826
--scenario improve
1927
```
2028

21-
The offline CLI remains self-contained and supports fake mode only. For an
29+
The same CLI also supports trace replay:
30+
31+
```bash
32+
python examples/optimization/eval_optimize_loop/run_pipeline.py \
33+
--config examples/optimization/eval_optimize_loop/pipeline.trace.json \
34+
--scenario overfit
35+
```
36+
37+
For an
2238
explicit real integration smoke run, configure the OpenAI-compatible business
2339
model connection in the environment:
2440

@@ -128,7 +144,7 @@ usage remain `unavailable` because business-agent calls may not expose complete
128144
telemetry. In real mode, optimizer rounds, reflection calls, duration, cost, and
129145
token usage are reported independently from the native optimizer result; an
130146
unreliable or incomplete field stays `unavailable` instead of being treated as
131-
zero. In fake mode, optimizer-only fields are `not_applicable`.
147+
zero. In offline mode, optimizer-only fields are `not_applicable`.
132148

133149
Run the Stage 1–5 tests with:
134150

examples/optimization/eval_optimize_loop/analysis.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from .case_diff import compare_evaluations
1212
from .evaluation_adapter import standardize_snapshot
1313
from .schemas import EvaluationAnalysis
14-
from .schemas import FakeEvaluationSnapshot
14+
from .schemas import EvaluationSnapshot
1515
from .schemas import ObservableValue
1616
from .schemas import OverfitStatus
1717

@@ -39,10 +39,10 @@ def _overfit_status(
3939

4040
def build_evaluation_analysis(
4141
*,
42-
baseline_train: FakeEvaluationSnapshot,
43-
baseline_validation: FakeEvaluationSnapshot,
44-
candidate_train: FakeEvaluationSnapshot,
45-
candidate_validation: FakeEvaluationSnapshot,
42+
baseline_train: EvaluationSnapshot,
43+
baseline_validation: EvaluationSnapshot,
44+
candidate_train: EvaluationSnapshot,
45+
candidate_validation: EvaluationSnapshot,
4646
hard_case_ids: set[str],
4747
critical_case_ids: set[str],
4848
severe_case_score_drop: float,

examples/optimization/eval_optimize_loop/artifact_writer.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from .report_builder import render_optimization_markdown
2424
from .schemas import ArtifactIndex, ArtifactReference, FailureReport
2525
from .schemas import OptimizationReport, ReportPhase
26+
from .schemas import TraceCandidateProposal
2627

2728

2829
ArtifactType: TypeAlias = Literal[
@@ -536,7 +537,7 @@ def publish_report_bundle(
536537
)
537538
)
538539

539-
input_specs = (
540+
input_specs = [
540541
(
541542
"input.pipeline_config",
542543
Path(report.input_snapshot.pipeline_config_path),
@@ -565,7 +566,32 @@ def publish_report_bundle(
565566
"validation_evalset.json",
566567
"baseline_validation",
567568
),
568-
)
569+
]
570+
if (
571+
isinstance(report.candidate, TraceCandidateProposal)
572+
and report.input_snapshot.trace_inputs is not None
573+
):
574+
trace = report.input_snapshot.trace_inputs.scenarios[
575+
report.candidate.scenario
576+
]
577+
input_specs.extend(
578+
[
579+
(
580+
"input.trace.candidate_train",
581+
Path(trace.train_evalset_path),
582+
trace.train_evalset_sha256,
583+
"candidate_train_trace.json",
584+
"candidate_train",
585+
),
586+
(
587+
"input.trace.candidate_validation",
588+
Path(trace.validation_evalset_path),
589+
trace.validation_evalset_sha256,
590+
"candidate_validation_trace.json",
591+
"candidate_validation",
592+
),
593+
]
594+
)
569595
for artifact_id, source, expected_hash, destination_name, produced_by in input_specs:
570596
if copy_input_files:
571597
content_validator = (
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# Tencent is pleased to support the open source community by making tRPC-Agent-Python available.
2+
#
3+
# Copyright (C) 2026 Tencent. All rights reserved.
4+
#
5+
# tRPC-Agent-Python is licensed under the Apache License, Version 2.0.
6+
"""共享的 SDK 业务 Agent:模型可注入,Prompt 每次重新读取。"""
7+
8+
from __future__ import annotations
9+
10+
from collections.abc import Callable
11+
from uuid import uuid4
12+
13+
from trpc_agent_sdk.agents import LlmAgent
14+
from trpc_agent_sdk.evaluation import TargetPrompt
15+
from trpc_agent_sdk.models import LLMModel
16+
from trpc_agent_sdk.runners import Runner
17+
from trpc_agent_sdk.sessions import InMemorySessionService
18+
from trpc_agent_sdk.types import Content
19+
from trpc_agent_sdk.types import GenerateContentConfig
20+
from trpc_agent_sdk.types import Part
21+
22+
23+
ModelFactory = Callable[[], LLMModel]
24+
25+
26+
def render_instruction(prompts: dict[str, str]) -> str:
27+
"""稳定拼接一个或多个工作 Prompt 字段。"""
28+
if len(prompts) == 1:
29+
return next(iter(prompts.values()))
30+
return "\n\n".join(
31+
f"## {name}\n{content}" for name, content in prompts.items()
32+
)
33+
34+
35+
class BusinessAgent:
36+
"""以独立 SDK Session 执行一次 Prompt 敏感的业务请求。"""
37+
38+
def __init__(
39+
self,
40+
target_prompt: TargetPrompt,
41+
model_factory: ModelFactory,
42+
*,
43+
agent_name: str,
44+
app_name: str,
45+
user_id: str,
46+
) -> None:
47+
self._target_prompt = target_prompt
48+
self._model_factory = model_factory
49+
self._agent_name = agent_name
50+
self._app_name = app_name
51+
self._user_id = user_id
52+
53+
async def call_agent(self, query: str) -> str:
54+
"""重新读取 Prompt,运行独立 Session,并返回最终可见文本。"""
55+
if not isinstance(query, str):
56+
raise TypeError("query must be a string")
57+
58+
prompts = await self._target_prompt.read_all()
59+
model = self._model_factory()
60+
root_agent = LlmAgent(
61+
name=self._agent_name,
62+
description="Evaluation and prompt optimization business agent.",
63+
model=model,
64+
instruction=render_instruction(prompts),
65+
generate_content_config=GenerateContentConfig(
66+
temperature=0.0,
67+
max_output_tokens=512,
68+
),
69+
)
70+
session_service = InMemorySessionService()
71+
runner = Runner(
72+
app_name=self._app_name,
73+
agent=root_agent,
74+
session_service=session_service,
75+
)
76+
session_id = str(uuid4())
77+
await session_service.create_session(
78+
app_name=self._app_name,
79+
user_id=self._user_id,
80+
session_id=session_id,
81+
state={},
82+
)
83+
message = Content(
84+
role="user",
85+
parts=[Part.from_text(text=query)],
86+
)
87+
final_text = ""
88+
async for event in runner.run_async(
89+
user_id=self._user_id,
90+
session_id=session_id,
91+
new_message=message,
92+
):
93+
if (
94+
not event.is_final_response()
95+
or not event.content
96+
or not event.content.parts
97+
):
98+
continue
99+
for part in event.content.parts:
100+
if not part.thought and part.text:
101+
final_text += part.text
102+
return final_text.strip()

examples/optimization/eval_optimize_loop/candidate_provider.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121

2222
from .fake.candidate_provider import DeterministicFakeCandidateProvider
2323
from .schemas import CandidateProposal
24-
from .schemas import FakeCandidateScenario
24+
from .schemas import CandidateScenario
2525
from .schemas import OptimizerCandidateProposal
2626
from .schemas import OptimizerRuntimeParameters
2727

@@ -75,7 +75,7 @@ async def propose(self, request: CandidateRequest) -> CandidateGeneration:
7575
class FakeCandidateProviderAdapter:
7676
"""Lift the pure synchronous fake provider into the common async boundary."""
7777

78-
def __init__(self, scenario: FakeCandidateScenario) -> None:
78+
def __init__(self, scenario: CandidateScenario) -> None:
7979
self._scenario = scenario
8080

8181
async def propose(self, request: CandidateRequest) -> CandidateGeneration:

examples/optimization/eval_optimize_loop/config.py

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,29 @@
3030

3131

3232
class ExecutionConfig(EvalBaseModel):
33-
"""How a later phase obtains an optimization candidate."""
33+
"""Pipeline execution mode and deterministic candidate scenario."""
3434

35-
mode: Literal["fake", "real", "trace"] = "fake"
36-
fake_candidate_scenario: Literal["improve", "no_improvement", "overfit"] = "improve"
37-
use_fake_judge: bool = False
35+
mode: Literal["offline", "real", "trace"] = "offline"
36+
candidate_scenario: Literal["improve", "no_improvement", "overfit"] = "improve"
37+
38+
@model_validator(mode="before")
39+
@classmethod
40+
def _reject_removed_execution_options(cls, value: object) -> object:
41+
if not isinstance(value, dict):
42+
return value
43+
if value.get("mode") == "fake":
44+
raise ValueError("execution.mode='fake' was renamed to 'offline'")
45+
if "use_fake_judge" in value:
46+
raise ValueError(
47+
"execution.use_fake_judge was removed; configure evaluation "
48+
"metrics or rubric explicitly in optimizer.json"
49+
)
50+
if "fake_candidate_scenario" in value:
51+
raise ValueError(
52+
"execution.fake_candidate_scenario was renamed to "
53+
"execution.candidate_scenario"
54+
)
55+
return value
3856

3957

4058
class InputPathsConfig(EvalBaseModel):
@@ -72,6 +90,37 @@ def _require_non_empty_relative_path(cls, value: str) -> str:
7290
return value
7391

7492

93+
class TraceCandidateInputsConfig(EvalBaseModel):
94+
"""一个 Trace 候选版本的评测集和 Prompt 快照路径。"""
95+
96+
train_evalset: str
97+
validation_evalset: str
98+
prompts: list[PromptFieldConfig] = Field(min_length=1)
99+
100+
@field_validator("train_evalset", "validation_evalset")
101+
@classmethod
102+
def _require_relative_trace_path(cls, value: str) -> str:
103+
if not value.strip() or Path(value).is_absolute():
104+
raise ValueError("trace evalset path must be a non-empty relative path")
105+
return value
106+
107+
108+
class TraceInputsConfig(EvalBaseModel):
109+
"""三个确定性候选场景的 Trace 输入。"""
110+
111+
candidates: dict[
112+
Literal["improve", "no_improvement", "overfit"],
113+
TraceCandidateInputsConfig,
114+
]
115+
116+
@model_validator(mode="after")
117+
def _require_all_scenarios(self) -> "TraceInputsConfig":
118+
required = {"improve", "no_improvement", "overfit"}
119+
if set(self.candidates) != required:
120+
raise ValueError("trace_inputs must define improve, no_improvement, and overfit")
121+
return self
122+
123+
75124
class RunConfig(EvalBaseModel):
76125
"""Reproducibility and workspace location settings."""
77126

@@ -186,12 +235,31 @@ class PipelineConfig(EvalBaseModel):
186235
reporting: ReportingConfig = Field(default_factory=ReportingConfig)
187236
artifacts: ArtifactConfig = Field(default_factory=ArtifactConfig)
188237
writeback: WritebackConfig = Field(default_factory=WritebackConfig)
238+
trace_inputs: Optional[TraceInputsConfig] = None
189239

190240
@model_validator(mode="after")
191241
def _require_unique_prompt_names(self) -> "PipelineConfig":
192242
names = [prompt.name for prompt in self.prompts]
193243
if len(names) != len(set(names)):
194244
raise ValueError("prompts must not contain duplicate field names")
245+
if self.execution.mode == "trace":
246+
if self.trace_inputs is None:
247+
raise ValueError("trace mode requires trace_inputs")
248+
if self.writeback.enabled:
249+
raise ValueError("trace mode does not allow source Prompt writeback")
250+
expected = set(names)
251+
for scenario, inputs in self.trace_inputs.candidates.items():
252+
candidate_names = [prompt.name for prompt in inputs.prompts]
253+
if len(candidate_names) != len(set(candidate_names)):
254+
raise ValueError(
255+
f"trace candidate {scenario} has duplicate prompt names"
256+
)
257+
if set(candidate_names) != expected:
258+
raise ValueError(
259+
f"trace candidate {scenario} prompt fields must match baseline"
260+
)
261+
elif self.trace_inputs is not None:
262+
raise ValueError("trace_inputs is only allowed in trace mode")
195263
return self
196264

197265

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"eval_set_id":"trace_train","eval_cases":[{"eval_id":"train_output_format","eval_mode":"trace","conversation":[{"invocation_id":"train_output_format","user_content":{"role":"user","parts":[{"text":"How can I update my email address?"}]},"final_response":{"role":"model","parts":[{"text":"{\"route\":\"account\",\"message\":\"Open profile settings to update your email.\"}"}]}}],"actual_conversation":[{"invocation_id":"train_output_format","user_content":{"role":"user","parts":[{"text":"How can I update my email address?"}]},"final_response":{"role":"model","parts":[{"text":"{\"route\":\"account\",\"message\":\"Open profile settings to update your email.\"}"}]}}]},{"eval_id":"train_tool_choice","eval_mode":"trace","conversation":[{"invocation_id":"train_tool_choice","user_content":{"role":"user","parts":[{"text":"Check the status of order A100."}]},"final_response":{"role":"model","parts":[{"text":"{\"route\":\"order_lookup\",\"message\":\"Checking order A100.\"}"}]}}],"actual_conversation":[{"invocation_id":"train_tool_choice","user_content":{"role":"user","parts":[{"text":"Check the status of order A100."}]},"final_response":{"role":"model","parts":[{"text":"{\"route\":\"general_support\",\"message\":\"Please provide more details so I can route your request.\"}"}]}}]},{"eval_id":"train_tool_arguments","eval_mode":"trace","conversation":[{"invocation_id":"train_tool_arguments","user_content":{"role":"user","parts":[{"text":"Look up order B-204 for customer 17."}]},"final_response":{"role":"model","parts":[{"text":"{\"route\":\"order_lookup\",\"message\":\"Checking order B-204 for customer 17.\"}"}]}}],"actual_conversation":[{"invocation_id":"train_tool_arguments","user_content":{"role":"user","parts":[{"text":"Look up order B-204 for customer 17."}]},"final_response":{"role":"model","parts":[{"text":"{\"route\":\"general_support\",\"message\":\"Please provide more details so I can route your request.\"}"}]}}]}]}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"eval_set_id":"trace_validation","eval_cases":[{"eval_id":"val_paraphrase","eval_mode":"trace","conversation":[{"invocation_id":"val_paraphrase","user_content":{"role":"user","parts":[{"text":"Where do I change the address tied to my account?"}]},"final_response":{"role":"model","parts":[{"text":"{\"route\":\"account\",\"message\":\"Open profile settings to update your address.\"}"}]}}],"actual_conversation":[{"invocation_id":"val_paraphrase","user_content":{"role":"user","parts":[{"text":"Where do I change the address tied to my account?"}]},"final_response":{"role":"model","parts":[{"text":"{\"route\":\"general_support\",\"message\":\"Please provide more details so I can route your request.\"}"}]}}]},{"eval_id":"val_knowledge_recall","eval_mode":"trace","conversation":[{"invocation_id":"val_knowledge_recall","user_content":{"role":"user","parts":[{"text":"How long does standard shipping usually take?"}]},"final_response":{"role":"model","parts":[{"text":"{\"route\":\"shipping_policy\",\"message\":\"Standard shipping normally takes 3-5 business days.\"}"}]}}],"actual_conversation":[{"invocation_id":"val_knowledge_recall","user_content":{"role":"user","parts":[{"text":"How long does standard shipping usually take?"}]},"final_response":{"role":"model","parts":[{"text":"{\"route\":\"general_support\",\"message\":\"Please provide more details so I can route your request.\"}"}]}}]},{"eval_id":"val_refund_route","eval_mode":"trace","conversation":[{"invocation_id":"val_refund_route","user_content":{"role":"user","parts":[{"text":"I was charged twice and need the duplicate payment refunded."}]},"final_response":{"role":"model","parts":[{"text":"{\"route\":\"billing_refund\",\"message\":\"I will route this duplicate charge for refund review.\"}"}]}}],"actual_conversation":[{"invocation_id":"val_refund_route","user_content":{"role":"user","parts":[{"text":"I was charged twice and need the duplicate payment refunded."}]},"final_response":{"role":"model","parts":[{"text":"{\"route\":\"billing_refund\",\"message\":\"I will route this duplicate charge for refund review.\"}"}]}}]}]}

0 commit comments

Comments
 (0)