Part 2用LLM解析人类写的文本运维备注、修正结构化标志位,并生成决策解释,同时对比5个小模型的实际效果差异。
这是 5 篇系列文章的第 2 部分。第 1 部分为单个 rideshare 区域构建了一个基于规则的 agent——没有 LLM,只有结构化的数字和确定性规则。
它能工作,但有一个明显的漏洞。运维人员不会填写结构化字段。他们会写类似"活动取消了,而且附近场地有事故导致交通拥堵"这样的话。第 1 阶段的 agent 只能理解 rain_flag/event_flag/traffic 作为原始值。它完全没有办法读取那样的句子。
这正是 LLM 发挥作用的地方——它只做两件事,而且仅此而已:
调和运维备注。在决策运行之前,读取自由文本并修正 rain_flag/event_flag/traffic。
解释决策。在策略已经选定之后,为人工审核者写一份通俗易懂的说明。
策略选择本身没有任何变化。它完全复用了第 1 部分的确定性逻辑——从不重做,从不让 LLM 来二次猜测。
一个小型本地模型并不是天然可信的,即使在这样一个狭窄的任务上。所以我也在旁边对比了五个模型,看看哪个真正站得住脚。
这个项目早期版本尝试过那样做。它用了一个 ReAct 循环,LLM 调用多个工具并通过自由文本输出自己选择最终策略。
它没能站住脚跟。即使是 qwen2.5:14b 有时也会比较错误的候选对,或者从自己的答案中漏掉最佳选项。那不是 prompt 调优的问题——而是这个任务用了错误的工具。
LLM 不会计算 argmax。它生成看起来合理的文本,但不保证每个候选都真正被比较过。max() 从不会犯这种错误。
我在整个项目中使用的规则是:如果一个任务可以用普通数字和固定答案来解决,就写代码。代码每次都能做对。只有当输入是一个查表无法解析的句子,或者输出是一个查表无法写出的句子时,LLM 才能发挥作用。把一个普通的数值决策交给 LLM 并不会增加能力——只是增加了一种新的出错方式。
reconcile_inputs 读取真实句子,所以是自由文本输入。generate_explanation 写出一个句子,所以是自由文本输出。从四个数字中选出最有利可图的策略两者都不是,所以它留在纯 Python 中:
# Scenario: 4 drivers, 14 rider requests, event nearby
for policy in ["surge_pricing", "driver_bonus", "demand_redirect", "do_nothing"]:
r = evaluate_policy({"driver_count": 4, "rider_request_count": 14,
"rain_flag": False, "event_flag": True}, policy, noise=False)
print(f"{policy:<20} profit=${r['profit']:>8.2f} resolved={r['resolved']}")
surge_pricing profit=$ 75.61 resolved=False
driver_bonus profit=$ 56.66 resolved=False
demand_redirect profit=$ 62.96 resolved=False
do_nothing profit=$ 51.60 resolved=False
与第 1 部分相同的 evaluate_policy 函数,直接调用。没有任何 LLM 介入。
两个单次 LLM 调用被添加进来,其他没有任何变化:
detect_imbalance ──▶ classify_severity ──▶ set_candidates
│
┌────────────┴────────────┐
▼ (balanced) ▼ (deficit/surplus)
trivial_do_nothing reconcile_inputs ← LLM #1
│ ▼
│ resolved_imbalance
│ ▼
│ choose_best_policy
│ ▼
│ generate_explanation ← LLM #2
│ │
└──────────────┬───────────┘
▼
simulate_and_report
detect_imbalance、classify_severity、resolved_imbalance 和 choose_best_policy 直接从第 1 部分的模块导入。相同的确定性逻辑——不在这里重做。
两个新节点都是单次的,不是循环:
reconcile_inputs 只在有运维备注时运行。它调用一个工具,但只是为了获取结构化数据回来。工具的代码实际上从不运行——调用它只是模型报告答案的方式,而不是写一段话。
generate_explanation 在 choose_best_policy 已经决定之后运行。它是纯文本,不需要工具。它不会把决策搞错,因为它从不触碰决策本身。
平衡区域完全跳过两者。一个候选,不需要调和或解释——零次模型调用。
与第 1 部分相同的设计。添加了三个字段,用于 LLM 现在贡献的内容:
explanation —— 一个输出
messages —— 原始 LLM 对话,保留下来供检查
class AgentState(TypedDict):
zone: dict
ops_note: str
imbalance_ratio: float
imbalance_type: str
severity: str
candidate_policies: list
policy_evaluations: dict
policy_resolutions: dict
recommended_policy: str
explanation: str
messages: Annotated[list[BaseMessage], operator.add]
outcome: dict
outcome_delta: dict
report: str
reconcile_inputs 不允许模型写回自由文本段落。相反,它给 LLM 提供恰好一个工具,并要求它调用一次——用需要报告的三个字段:
@tool
def report_context(rain_flag: bool, event_flag: bool,
traffic_level: Literal["none", "light", "moderate", "heavy"]) -> str:
"""
Call this exactly once with your interpretation of rain_flag, event_flag, and
traffic_level for this decision, after considering the ops note. If nothing in
the note changes a value, report the zone's original value unchanged.
traffic_level anchors: "none" = no congestion mentioned/normal; "light" = some
congestion, minor delays; "moderate" = noticeable backup, routes slower than
usual; "heavy" = gridlock, accident, or road closure — significant delays.
Only road/vehicle traffic counts here — foot traffic (pedestrian crowds) is a
separate concept and should NOT affect this value.
"""
return f"rain_flag={rain_flag}, event_flag={event_flag}, traffic_level={traffic_level}"
工具的函数体实际上从不运行。调用它只是模型干净地交回恰好三个值的方式:两个布尔值和一个固定标签。这比一段我得手动解析的文字要好。
这里的每个节点都是一个普通函数,与第 1 部分相同。reconcile_inputs 和 generate_explanation 只是多了一个 llm 参数。所以构建图意味着先绑定一个特定的模型。
我把它包装在一个 build_agent(llm) 函数里,而不是内联构建一次。这样做的原因是:下面的模型对比需要完全相同的图,分别重建五次,每个 LLM 一次。结构从不改变。只有绑定给它的模型不同。
def build_agent(llm):
llm_with_reconcile_tool = llm.bind_tools([report_context])
def _reconcile(state):
return reconcile_inputs(state, llm_with_reconcile_tool)
def _explain(state):
return generate_explanation(state, llm)
g = StateGraph(AgentState)
g.add_node("detect_imbalance", detect_imbalance)
g.add_node("classify_severity", classify_severity)
g.add_node("set_candidates", set_candidates)
g.add_node("trivial_do_nothing", trivial_do_nothing)
g.add_node("reconcile_inputs", _reconcile)
g.add_node("resolved_imbalance", resolved_imbalance)
g.add_node("choose_best_policy", choose_best_policy)
g.add_node("generate_explanation", _explain)
g.add_node("simulate_and_report", simulate_and_report)
g.add_edge(START, "detect_imbalance")
g.add_edge("detect_imbalance", "classify_severity")
g.add_edge("classify_severity", "set_candidates")
g.add_conditional_edges("set_candidates", route_llm_or_skip, {
"trivial_do_nothing": "trivial_do_nothing",
"reconcile_inputs": "reconcile_inputs",
})
g.add_edge("reconcile_inputs", "resolved_imbalance")
g.add_edge("resolved_imbalance", "choose_best_policy")
g.add_edge("choose_best_policy", "generate_explanation")
g.add_edge("generate_explanation", "simulate_and_report")
g.add_edge("trivial_do_nothing", "simulate_and_report")
g.add_edge("simulate_and_report", END)
return g.compile()
策略选择这里不可能出错。它仍然是确定性的。真正因模型而异的范围更窄:它是否正确读取了运维备注并报告了修正后的 event_flag 和 traffic_level。
场景:Stadium Area 的原始快照有 event_flag=True 且无交通。运维备注说活动取消了,另外描述了一场事故导致主路拥堵。这是两个修正。它还有一个陷阱短语——"foot traffic"——根本不应该影响 traffic_level:
OPS_NOTE = (
"The stadium event just got cancelled — foot traffic will be much lower than "
"usual tonight. There's also a bad accident on the main road backing up traffic "
"near the venue."
)
正确读取此备注的模型应报告 event_flag=False 和 traffic_level="heavy"。而且不让"foot traffic"泄漏到第二个值中。
我首先运行了四个 7-8B 模型——这是大多数人在笔记本电脑上可以舒适运行的范围。然后我升级到 qwen2.5:14b,看看额外的参数带来了什么:
重要的模式不是任何单独一行。而是 llama3.1:8b 和 mistral:7b 在两次运行完全相同的 notebook 之间交换了谁失败——相同的 prompt、相同的 temperature=0。这具体证明了一次干净的运行不是可靠性的证据。
deepseek-r1:8b 是一个看起来有系统性而非随机性的例外。它在任何一次运行中都不调用工具。它的 <think> 推理步骤不会可靠地转化为结构化工具输出。
qwen2.5:14b(~9GB,在 16GB M2 上仍然可以舒适运行)是唯一一个在每次运行这个 notebook 时都正确站住脚的模型。
模型选择比 prompt 调优在这里更重要——尽管任务本身范围很窄:读取两到三个句子,报告两个布尔值和一个有界标签。
这个项目的最终模型:qwen2.5:14b。每个后续部分都默认使用它。不是因为它是可用的最大模型——而是因为它是实际能每次都正确站住脚的最小模型。
LLM 的工作范围保持狭窄。读取一个查表无法解析的句子。写出一个查表无法产生的句子。两者都不触碰实际决策。
剩余的差距:即使一个正确的策略选择通常也留下 resolved=False。一个 15 分钟的窗口通常不足以解决严重缺口。而且 agent 没有对已经尝试过的记忆。这就是第 3 部分要解决的问题。
本系列代码:github.com/ebiarian/zone-balancing-ridesharing-langgraph-agent
下一篇——第 3 部分:通过 MemorySaver 赋予 agent 跨周期记忆,这样它可以随时间应用策略而不是做一次决策就遗忘。