L1 那 80 行能跑, 但能跑 ≠ 能用。production 必备的 4 件——看见在干啥、崩了能续、能塞政策、能拆任务——这一节逐件加, 每件 ~30 行, 完成后你的 ~250 行 harness 已经覆盖三家框架 80% 内核能力。
建议先读《L1 / 起步 / 把 40 行 harness 跑起来》;如果你是跳读,至少先看本章索引与本页 TL;DR。
课程实操闭环,要求你把抽象真正写进代码与运行流程。
L1 那 80 行能跑, 但 能跑 ≠ 能用 。production 必备的 4 件—— 看见在干啥 、 崩了能续 、 能塞政策 、 能拆任务 ——这一节 逐件加 , 每件 ~30 行, 完成后你的 ~250 行 harness 已经覆盖三家框架 80% 内核能力 。
先判断任务类型,再决定手搓 harness 还是借框架。
课程实操:可运行案例 + 官方 SDK / 仓库。
~250 行, 覆盖 6 条不变量 + 4 件工程化。可以拿它跑真实业务场景了——这就是 L3 案例的起点。L1 你用 print(f"step {step_no+1}") 看 trace——本地开发没问题。但: ① 跑 100 个 query 后 print 全混在一起, 找不到哪个 query 对应哪一段; ② 部署到 server 上 print 上不到日志聚合; ③ 想分析"第 3 步耗时多久"或"这个 tool 失败率"无数据可统计。必须有结构化 trace。
三家框架的解法(章 5-04 复习): Agents SDK 用 BatchTraceProcessor + 6 种 span; LangChain 走 LangSmith / OpenTelemetry; smolagents 把 step list 当天然 trace。共同最小公分母: 每步发出一个带 trace_id + span_id + parent_id 的 JSON 事件。
"""minimal tracer · stdout JSONL · OTel-compatible field names.""" import json, os, time, uuid from contextvars import ContextVar from contextlib import contextmanager _current_trace: ContextVar[str | None] = ContextVar("trace", default=None) _current_span: ContextVar[str | None] = ContextVar("span", default=None) LOG_PATH = os.environ.get("AGENT_TRACE_LOG", "trace.jsonl") def _emit(event: dict): open(LOG_PATH, "a").write(json.dumps(event) + "\n") @contextmanager def trace_run(name: str, **attrs): tid = uuid.uuid4().hex[:12] tok = _current_trace.set(tid) t0 = time.time() _emit({"kind": "trace_start", "trace_id": tid, "name": name, "ts": t0, **attrs}) try: yield tid finally: _emit({"kind": "trace_end", "trace_id": tid, "duration_ms": int((time.time() - t0) * 1000)}) _current_trace.reset(tok) @contextmanager def span(name: str, **attrs): sid = uuid.uuid4().hex[:8] parent = _current_span.get() tok = _current_span.set(sid) t0 = time.time() _emit({"kind": "span_start", "trace_id": _current_trace.get(), "span_id": sid, "parent_span_id": parent, "name": name, "ts": t0, **attrs}) try: result = (yield) _emit({"kind": "span_end", "span_id": sid, "duration_ms": int((time.time() - t0) * 1000), "status": "ok"}) except Exception as e: _emit({"kind": "span_end", "span_id": sid, "status": "error", "error": repr(e)}) raise finally: _current_span.reset(tok)
from tracer import trace_run, span # +import def run_agent(user_input, *, max_steps=8, model="openai/gpt-4o-mini"): with trace_run("agent_run", model=model, query=user_input): # +wrap client = OpenAI(...) history = [Step("user", user_input)] for step_no in range(max_steps): with span("step", step_no=step_no): # +wrap with span("llm_call", model=model): # +wrap rsp = client.chat.completions.create(...) msg = rsp.choices[0].message if not msg.tool_calls: history.append(Step("assistant", msg.content or "")) return msg.content or "" for tc in msg.tool_calls: with span("tool_call", name=tc.function.name): # +wrap result = TOOLS[tc.function.name](**json.loads(tc.function.arguments)) history.append(Step("tool", result, tool_call_id=tc.id))
$ python minimal_agent.py
$ jq -c '.' trace.jsonl | head -8
{"kind":"trace_start","trace_id":"a1b2c3d4e5f6","name":"agent_run","model":"openai/gpt-4o-mini","query":"sf weather?",...}
{"kind":"span_start","trace_id":"a1b2...","span_id":"01a2","parent_span_id":null,"name":"step","step_no":0,...}
{"kind":"span_start","trace_id":"a1b2...","span_id":"01a3","parent_span_id":"01a2","name":"llm_call",...}
{"kind":"span_end","span_id":"01a3","duration_ms":842,"status":"ok"}
{"kind":"span_start","trace_id":"a1b2...","span_id":"01a4","parent_span_id":"01a2","name":"tool_call","name":"get_weather",...}
{"kind":"span_end","span_id":"01a4","duration_ms":2,"status":"ok"}
{"kind":"span_end","span_id":"01a2","duration_ms":848,"status":"ok"}
{"kind":"trace_end","trace_id":"a1b2c3d4e5f6","duration_ms":1230}
把这份 JSONL 喂给 jq 你能立刻算 P95 step 耗时, 喂给 grafana 能画 service map——这就是 trace 的本质。