Grounding、Fencing 与 Memory 深度解析
这一页关注输入可信度:外部文本如何被围栏包住,提示块如何带上动态上下文,记忆事实又怎样在写入前被过滤和分层挑选。
导入链与契约
公共安全层先把外部文本降级为 data,再决定是否强制调用 grounding 工具;入口散布在 sources/commerce-agents/commerce-common/commerce_common/fencing.py:20、sources/commerce-agents/commerce-common/commerce_common/fencing.py:83、sources/commerce-agents/commerce-common/commerce_common/grounding.py:55、sources/commerce-agents/commerce-common/commerce_common/prompt_assembly.py:33,这让 prompt 不必假设检索内容天然可信。
83 @dataclass(frozen=True) 84 class Fence: 85 """The tag that wraps third-party content and the notice the static prompt carries 86 about it.""" 87 88 label: str 89 notice: str 90 91 @property 92 def open(self) -> str: 93 return f"<{self.label}>" 94 95 @property 96 def close(self) -> str: 97 return f"</{self.label}>" 98 99 def sanitize_text(self, text: str, max_chars: int | None = None) -> str: 100 """``max_chars`` bounds the result including the truncation suffix, so a schema 101 limit can be passed as is.""" 102 text = unicodedata.normalize("NFKC", text) 103 text = _INVISIBLE.sub("", text) 104 text = _CONTROL.sub(" ", text) 105 # Markers and tokens are removed to a fixpoint, so one nested inside another 106 # (``</label</label>>``) does not reassemble after the inner one goes. 107 marker = _marker_pattern(self.label) 108 while True: 109 stripped = _SPECIAL_TOKEN.sub("[removed]", marker.sub("[removed]", text)) 110 if stripped == text: 111 break 112 text = stripped 113 text = _TURN_INDICATOR.sub(r"\1\2 -", text) 114 if max_chars is not None and len(text) > max_chars: 115 suffix = " ...[truncated]" 116 if max_chars > len(suffix): 117 text = text[: max_chars - len(suffix)] + suffix 118 else: 119 text = text[:max_chars] 120 return text
核心实现,逐段拆
▸Prompt assembly 把 cache-control 与 dynamic context 分开
build_request_messages 先构建系统块,再决定 eager input、tool cache-control 和历史消息如何组合;sources/commerce-agents/commerce-common/commerce_common/prompt_assembly.py:25、sources/commerce-agents/commerce-common/commerce_common/prompt_assembly.py:43、sources/commerce-agents/commerce-common/commerce_common/prompt_assembly.py:52、sources/commerce-agents/commerce-common/commerce_common/prompt_assembly.py:60、sources/commerce-agents/shopping-agent/core/shopping_agent/prompt.py:197 显示它把稳定规则和每轮上下文分开处理。
60 def build_request_messages( 61 messages: list[dict[str, Any]], 62 *, 63 rolling_breakpoint: bool = True, 64 ) -> list[dict[str, Any]]: 65 """The outgoing request's messages: a request-shaped copy of ``messages`` with the 66 rolling cache breakpoint on the newest persisted content block. 67 68 Within a turn the system blocks are constant, so the marker makes each round's prior 69 rounds — the long tool results especially — a cache read instead of a reprocess. Two 70 cases skip it. A bare first call (one message) would write an entry a one-shot 71 session never reads, and the next call marks a later block whose span carries the 72 first message anyway. And the caller passes ``rolling_breakpoint=False`` on rounds 73 whose ``tool_choice`` is not ``auto``, because ``tool_choice`` keys the messages 74 span: an entry written under a forced round is unreadable by the auto rounds that 75 follow. 76 77 A turn that closed on a presentation round leaves a tool_result message followed by 78 the next user message; the two go out as one user message, tool_result blocks first. 79 80 Applied per call, to the outgoing request only: the returned list shallow-copies the 81 touched message and blocks, strips the marker any earlier call placed, and never 82 mutates the host's persisted history. String content is lifted into a one-block list 83 because ``cache_control`` lives on content blocks. 84 """ 85 if not messages: 86 return [] 87 88 def without_marker(message: dict[str, Any]) -> dict[str, Any]: 89 content = message.get("content") 90 if not isinstance(content, list) or not any( 91 isinstance(b, dict) and "cache_control" in b for b in content 92 ): 93 return message 94 return message | { 95 "content": [ 96 {k: v for k, v in b.items() if k != "cache_control"} if isinstance(b, dict) else b 97 for b in content 98 ] 99 } 100 101 def blocks(raw: Any) -> list[Any]: 102 return [{"type": "text", "text": raw}] if isinstance(raw, str) else list(raw or []) 103 104 request: list[dict[str, Any]] = [] 105 for message in messages: 106 message = without_marker(message) 107 if request and message.get("role") == "user" and request[-1].get("role") == "user": 108 request[-1] = request[-1] | { 109 "content": blocks(request[-1].get("content")) + blocks(message.get("content")) 110 } 111 else: 112 request.append(message) 113 if not rolling_breakpoint or len(request) < 2: 114 return request 115 content = blocks(request[-1].get("content")) 116 if content and isinstance(content[-1], dict): 117 content[-1] = {**content[-1], "cache_control": {"type": "ephemeral"}} 118 request[-1] = request[-1] | {"content": content} 119 return request
▸MemoryWriteFilter 拦截敏感与短期事实
记忆层并非把所有对话都保存;sources/commerce-agents/commerce-common/commerce_common/memory.py:78、sources/commerce-agents/commerce-common/commerce_common/memory.py:101、sources/commerce-agents/commerce-common/commerce_common/memory.py:130、sources/commerce-agents/commerce-common/commerce_common/memory.py:150、sources/commerce-agents/commerce-common/commerce_common/memory.py:216 先过滤禁止模式、会话性内容和无效 fact,再挑选 tier-one fact 注入后续请求。
78 class MemoryStore(Protocol): 79 """Implement against your own storage. ``subject_id`` is the user or merchant the 80 facts belong to.""" 81 82 async def get_facts(self, subject_id: str) -> list[MemoryFact]: ... 83 84 async def upsert_facts(self, subject_id: str, facts: list[MemoryFact]) -> None: ... 85 86 async def search_facts(self, subject_id: str, query: str) -> list[MemoryFact]: ... 87 88 async def delete_fact(self, subject_id: str, key: str) -> bool: 89 """Remove one fact by key; True when something was deleted.""" 90 ... 91 92 async def clear(self, subject_id: str) -> None: 93 """Purge every fact for the subject and advance its purge generation.""" 94 ... 95 96 async def purge_generation(self, subject_id: str) -> int: 97 """How many times the subject has been purged; 0 for a subject never purged.""" 98 ... 99 100 101 MEMORY_STORE_METHODS: tuple[str, ...] = tuple( 102 name 103 for name, member in vars(MemoryStore).items() 104 if callable(member) and not name.startswith("_") 105 ) 106 107 108 def check_memory_store(store: MemoryStore) -> MemoryStore: 109 """Raise ``TypeError`` naming the first :class:`MemoryStore` method ``store`` lacks. 110 Runs where a store enters a deployment, so a partial store fails at startup instead 111 of inside the post-turn extraction pass, whose failures do not stop the turn.""" 112 for name in MEMORY_STORE_METHODS: 113 if not callable(getattr(store, name, None)): 114 raise TypeError( 115 f"{type(store).__name__} does not implement MemoryStore.{name}; the store " 116 f"contract is {', '.join(MEMORY_STORE_METHODS)}" 117 ) 118 return store
关键流程
这条路径的强点是每一步都可以单测:sources/commerce-agents/commerce-common/tests/test_fencing.py:1、sources/commerce-agents/commerce-common/tests/test_grounding.py:1、sources/commerce-agents/commerce-common/tests/test_prompt_assembly.py:1、sources/commerce-agents/commerce-common/tests/test_memory_facts.py:1、sources/commerce-agents/commerce-common/tests/test_memory_runtime.py:1 分别检查文本清洗、工具强制、prompt 结构和 memory runtime。
边界 / 失败模式
- sources/commerce-agents/commerce-common/commerce_common/fencing.py:42 会清理 bidi/control 字符,避免不可见文本改变 prompt 语义。
- sources/commerce-agents/commerce-common/commerce_common/grounding.py:72 只返回第一个强制工具,因此多规则优先级要在配置里明确。
- sources/commerce-agents/commerce-common/commerce_common/memory.py:183 的匹配选择偏轻量,生产级召回仍需要租户与权限边界。
▸Skill 与 prompt 如何共享可信边界
技能加载、系统块和记忆事实并不是三套孤立机制;sources/commerce-agents/commerce-common/commerce_common/skills.py:42、sources/commerce-agents/commerce-common/commerce_common/prompt_assembly.py:103 与 sources/commerce-agents/commerce-common/commerce_common/memory.py:248 共同决定本轮哪些知识可以进入模型上下文。