Fase 4: puerta 1 (eval-loss offline por bucket) y contenedor/scripts de puertas 2-4
- scripts/30_eval_suite.py --gate 1: eval-loss sobre el checkpoint mergeado, agrupado por meta.bucket (aislando replay), comparado contra eval_loss=0.275 de Fase 3. - docker-compose.eval.yml: servicio vllm-eval propio (puerto 8001), sirviendo el checkpoint mergeado en BF16, con tool-call-parser=qwen3_coder y reasoning-parser=qwen3. No se pudo leer el compose real de produccion (/data/compose/43/docker-compose.yml no existe en spark, probablemente vive en el host del servidor Portainer) -- flags basados en la arquitectura conocida del modelo. - scripts/31_build_holdout_prompts.py: genera data/holdout_prompts.jsonl (200 prompts, 40 por MCP, sin overlap verificado contra train.jsonl/eval.jsonl). - scripts/32_gate2_toolcalls.py: valida tool-calls devueltas por vllm-eval (parser real de vLLM, nunca una regex propia) contra los 200 prompts held-out. - scripts/33_gate3_adherencia.py: checklists de adherencia por skill + no-activacion, con baseline opcional contra vllm-qwen36 si esta corriendo. - scripts/34_gate4_e2e.py: arma el plan de llamadas E2E contra los 5 MCPs y 5 skills via el checkpoint mergeado, para que el agente orquestador las ejecute con sus MCPs reales.
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
"""Fase 4 -- Puerta 2: validez de tool-calls contra el parser real de vLLM.
|
||||
|
||||
Corre LOCALMENTE (no necesita GPU) contra el endpoint HTTP del contenedor de eval propio
|
||||
(vllm-eval, docker-compose.eval.yml, puerto 8001 por defecto) ya levantado y respondiendo
|
||||
en /v1/models.
|
||||
|
||||
Para cada prompt de data/holdout_prompts.jsonl (~200, generados por
|
||||
scripts/31_build_holdout_prompts.py, sin overlap con train/eval): envia una sola llamada a
|
||||
/v1/chat/completions con las tools reales del MCP correspondiente y
|
||||
tool_choice="auto". El parseo de tool_calls (`--tool-call-parser=qwen3_coder`,
|
||||
configurado en docker-compose.eval.yml) lo hace vLLM en el servidor -- este script solo
|
||||
valida la RESPUESTA ya parseada (nunca re-implementa el parser con una regex propia):
|
||||
|
||||
- Si el modelo decide llamar una tool: valida que el nombre exista en el schema del MCP,
|
||||
que los argumentos parseen como JSON valido, y que las propiedades "required" del
|
||||
schema esten presentes.
|
||||
- Si el modelo NO llama ninguna tool: se cuenta aparte (no es un error per se, algunos
|
||||
prompts pueden resolverse sin tool-call, pero se reporta la tasa).
|
||||
|
||||
Reporta: % de prompts con tool_call sintacticamente valido (parseado sin excepcion por
|
||||
vLLM, arguments=JSON valido, nombre y campos requeridos correctos) por MCP y global.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
HOLDOUT_PATH = REPO_ROOT / "data" / "holdout_prompts.jsonl"
|
||||
RESULTS_PATH = REPO_ROOT / "data" / "gate2_results.json"
|
||||
BASE_URL = os.environ.get("VLLM_EVAL_URL", "http://localhost:8001")
|
||||
MODEL_NAME = os.environ.get("VLLM_EVAL_MODEL", "qwen3.6-35b-a3b-mcp-bf16")
|
||||
|
||||
|
||||
def load_holdout():
|
||||
examples = []
|
||||
with open(HOLDOUT_PATH, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line:
|
||||
examples.append(json.loads(line))
|
||||
return examples
|
||||
|
||||
|
||||
def tool_by_name(tools, name):
|
||||
for tool in tools:
|
||||
if tool.get("name") == name or tool.get("function", {}).get("name") == name:
|
||||
return tool
|
||||
return None
|
||||
|
||||
|
||||
def to_openai_tools(tools):
|
||||
openai_tools = []
|
||||
for tool in tools:
|
||||
if "function" in tool:
|
||||
openai_tools.append(tool)
|
||||
else:
|
||||
openai_tools.append({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool["name"],
|
||||
"description": tool.get("description", ""),
|
||||
"parameters": tool.get("inputSchema") or tool.get("parameters") or {"type": "object", "properties": {}},
|
||||
},
|
||||
})
|
||||
return openai_tools
|
||||
|
||||
|
||||
def validate_tool_call(tool_call, tools):
|
||||
name = tool_call["function"]["name"]
|
||||
raw_args = tool_call["function"]["arguments"]
|
||||
try:
|
||||
args = json.loads(raw_args)
|
||||
except json.JSONDecodeError as e:
|
||||
return False, f"arguments no es JSON valido: {e}"
|
||||
|
||||
tool_def = tool_by_name(tools, name)
|
||||
if tool_def is None:
|
||||
return False, f"tool_call a nombre inexistente en el schema del MCP: {name}"
|
||||
|
||||
schema = tool_def.get("inputSchema") or tool_def.get("parameters") or {}
|
||||
required = schema.get("required", [])
|
||||
missing = [r for r in required if r not in args]
|
||||
if missing:
|
||||
return False, f"faltan campos requeridos {missing} en la llamada a {name}"
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
def call_vllm(prompt, tools, timeout=120):
|
||||
payload = {
|
||||
"model": MODEL_NAME,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"tools": to_openai_tools(tools),
|
||||
"tool_choice": "auto",
|
||||
"max_tokens": 1024,
|
||||
"temperature": 0.0,
|
||||
}
|
||||
resp = requests.post(f"{BASE_URL}/v1/chat/completions", json=payload, timeout=timeout)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--limit", type=int, default=None)
|
||||
args = parser.parse_args()
|
||||
|
||||
examples = load_holdout()
|
||||
if args.limit:
|
||||
examples = examples[: args.limit]
|
||||
print(f"[INFO] {len(examples)} prompts held-out, endpoint={BASE_URL}")
|
||||
|
||||
results = []
|
||||
stats = defaultdict(lambda: {"total": 0, "valid_tool_call": 0, "no_tool_call": 0, "invalid": 0})
|
||||
|
||||
t0 = time.time()
|
||||
for i, ex in enumerate(examples):
|
||||
mcp = ex["mcp"]
|
||||
stats[mcp]["total"] += 1
|
||||
stats["__global__"]["total"] += 1
|
||||
try:
|
||||
response = call_vllm(ex["prompt"], ex["tools"])
|
||||
except Exception as e:
|
||||
results.append({"mcp": mcp, "prompt": ex["prompt"], "error": str(e)})
|
||||
stats[mcp]["invalid"] += 1
|
||||
stats["__global__"]["invalid"] += 1
|
||||
continue
|
||||
|
||||
message = response["choices"][0]["message"]
|
||||
tool_calls = message.get("tool_calls") or []
|
||||
if not tool_calls:
|
||||
stats[mcp]["no_tool_call"] += 1
|
||||
stats["__global__"]["no_tool_call"] += 1
|
||||
results.append({"mcp": mcp, "prompt": ex["prompt"], "tool_calls": None, "valid": None})
|
||||
continue
|
||||
|
||||
all_valid = True
|
||||
errors = []
|
||||
for tc in tool_calls:
|
||||
ok, err = validate_tool_call(tc, ex["tools"])
|
||||
if not ok:
|
||||
all_valid = False
|
||||
errors.append(err)
|
||||
|
||||
if all_valid:
|
||||
stats[mcp]["valid_tool_call"] += 1
|
||||
stats["__global__"]["valid_tool_call"] += 1
|
||||
else:
|
||||
stats[mcp]["invalid"] += 1
|
||||
stats["__global__"]["invalid"] += 1
|
||||
|
||||
results.append({
|
||||
"mcp": mcp,
|
||||
"prompt": ex["prompt"],
|
||||
"tool_calls": [tc["function"]["name"] for tc in tool_calls],
|
||||
"valid": all_valid,
|
||||
"errors": errors,
|
||||
})
|
||||
|
||||
if (i + 1) % 20 == 0:
|
||||
print(f"[INFO] {i + 1}/{len(examples)} prompts procesados")
|
||||
|
||||
dt = time.time() - t0
|
||||
print(f"\n=== Puerta 2 -- validez de tool-calls (parser real de vLLM) ===")
|
||||
print(f"[INFO] tiempo total: {dt:.1f}s\n")
|
||||
for mcp in sorted(stats):
|
||||
s = stats[mcp]
|
||||
pct_valid = 100 * s["valid_tool_call"] / s["total"] if s["total"] else 0
|
||||
print(
|
||||
f" {mcp:20s} total={s['total']:4d} valid={s['valid_tool_call']:4d} "
|
||||
f"no_tool_call={s['no_tool_call']:4d} invalid={s['invalid']:4d} "
|
||||
f"pct_valid={pct_valid:.1f}%"
|
||||
)
|
||||
|
||||
with open(RESULTS_PATH, "w", encoding="utf-8") as f:
|
||||
json.dump({"stats": stats, "results": results}, f, ensure_ascii=False, indent=2)
|
||||
print(f"\n[INFO] resultados detallados en {RESULTS_PATH}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user